Compare commits
92 Commits
eba7bff17a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b230c6bdf1 | |||
| 51350d476c | |||
| 565728032c | |||
| 0e6b3bcecf | |||
| 6a03ea4a20 | |||
| 4fb9bf6ff3 | |||
| 9b7a0565ba | |||
| da8fe10365 | |||
| 18da021526 | |||
| bd7ebde68e | |||
| ca2a8c8aa7 | |||
| 0c8442a8d1 | |||
| 98c820e3bd | |||
| 263717874c | |||
| 1242b22e4f | |||
| 89be009040 | |||
| eaa37a1192 | |||
| c8a4d31441 | |||
| 1e3d14bef2 | |||
| 0aab9d8db6 | |||
| 0a50f3c66b | |||
| 1240297cca | |||
| 5b0e6fe51b | |||
| 5170cc5f99 | |||
| a735bfd116 | |||
| 28f0a0d8e2 | |||
| 2ef18e759c | |||
| 77bd2db286 | |||
| 1b6a3604cf | |||
| cb37377346 | |||
| b2402f4c72 | |||
| 648325a690 | |||
| 27927f3ab1 | |||
| 276539c383 | |||
| e482e3261c | |||
| a926e90196 | |||
| 16cf2b9e30 | |||
| 0cf44e1dfa | |||
| 44bc7ee81e | |||
| 0f171edd75 | |||
| 9a45a7be6f | |||
| 9dd461e35e | |||
| 84e81371c1 | |||
| f11fee10e9 | |||
| bde9a2e33e | |||
| c5f300b670 | |||
| d0dc17a067 | |||
| cebd41a5ac | |||
| 57e6fe410e | |||
| 60aceb75af | |||
| 9d39f5c100 | |||
| 70aa4cbc4d | |||
| 721b049100 | |||
| 5a018d03cb | |||
| e5f8c7a812 | |||
| 5e09ba1d58 | |||
| fe13d1f34e | |||
| 7aa1421639 | |||
| 4d014e2f45 | |||
| 706aa8e6ba | |||
| bfb982cc27 | |||
| 29ce94c3e8 | |||
| e85cbc4eb1 | |||
| 7f4f63eb8e | |||
| c58fe0d332 | |||
| 88511843ce | |||
| e87840e95b | |||
| 33a246cd13 | |||
| 3992b40eda | |||
| 2c684532da | |||
| d3e3d30b42 | |||
| 84212764e4 | |||
| eedae3b9f0 | |||
| bfd9991a3a | |||
| 2ab5f07c7c | |||
| 48faddb3bd | |||
| f4221f6ce4 | |||
| b3eb429fa6 | |||
| 40b609a7d4 | |||
| 3a68c705c6 | |||
| 5d662c7fd5 | |||
| d4ca38b45a | |||
| 48622a99c2 | |||
| 9866673bab | |||
| 2f965166ba | |||
| e9ec48bf15 | |||
| 2c38941f6e | |||
| effd740e45 | |||
| 8bd50d0431 | |||
| 5c89d5ccf1 | |||
| 9216f01bf6 | |||
| aed83b91b9 |
@@ -0,0 +1,108 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Forgejo Actions workflow for Codeberg. It mirrors .github/workflows/release.yml
|
||||||
|
# and reuses the same scripts/ci-build-release.sh, so the actual build/package
|
||||||
|
# commands live in exactly one place. Codeberg has no Windows runners, but the
|
||||||
|
# Windows binary is cross-compiled with MinGW-w64 from the Linux job, so a single
|
||||||
|
# golang:1.22-bookworm container produces all three artifacts.
|
||||||
|
#
|
||||||
|
# Publishing needs a token that can write releases. Add a repository secret named
|
||||||
|
# RELEASE_TOKEN (a Codeberg access token with the "write:repository" scope) under
|
||||||
|
# Settings -> Actions -> Secrets. Without it the build still runs; only the
|
||||||
|
# upload step is skipped.
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
# Codeberg's hosted runners are tagged codeberg-tiny/small/medium (+ -lazy);
|
||||||
|
# there is no "docker" tag. The non-lazy runners cap at 2/5/10 min, which the
|
||||||
|
# three CGO cross-compiles blow past, so use the medium *-lazy* runner, which
|
||||||
|
# relaxes the wall-clock limit (it aims to finish within 24h).
|
||||||
|
runs-on: codeberg-medium-lazy
|
||||||
|
container:
|
||||||
|
image: golang:1.22-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Provide Node.js for JS actions
|
||||||
|
# golang:1.22-bookworm ships no Node, and Codeberg's runner does not
|
||||||
|
# inject one, so JS actions (checkout, forgejo-release) fail with
|
||||||
|
# "node: not found". A run step executes through the container shell and
|
||||||
|
# needs no Node itself, so it can install Node before those actions run.
|
||||||
|
# (If a "node version" error ever appears, swap Debian's nodejs 18 for a
|
||||||
|
# NodeSource node 20 install.)
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends nodejs
|
||||||
|
command -v node || ln -s "$(command -v nodejs)" /usr/local/bin/node
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install cross toolchain
|
||||||
|
# Same package list as the repo Dockerfile / GitHub workflow: native gcc
|
||||||
|
# plus X11/GL headers, the aarch64 cross compiler with arm64 runtime
|
||||||
|
# libs, MinGW-w64 for the Windows GUI binary, and zip for packaging.
|
||||||
|
run: |
|
||||||
|
dpkg --add-architecture arm64
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
libc6-dev-arm64-cross \
|
||||||
|
linux-libc-dev-arm64-cross \
|
||||||
|
gcc-mingw-w64-x86-64 \
|
||||||
|
binutils-mingw-w64-x86-64 \
|
||||||
|
pkg-config \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
xorg-dev \
|
||||||
|
libgl1-mesa-dev:arm64 \
|
||||||
|
libx11-dev:arm64 \
|
||||||
|
libxcursor-dev:arm64 \
|
||||||
|
libxrandr-dev:arm64 \
|
||||||
|
libxinerama-dev:arm64 \
|
||||||
|
libxi-dev:arm64 \
|
||||||
|
libxxf86vm-dev:arm64 \
|
||||||
|
zip
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
- name: Derive version
|
||||||
|
# On a release, strip the leading "v" so artifact names and the injected
|
||||||
|
# app version match the release tag (the release event still sets
|
||||||
|
# GITHUB_REF_TYPE=tag / GITHUB_REF_NAME=<tag>).
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and package
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.value }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/ci-build-release.sh
|
||||||
|
scripts/ci-build-release.sh
|
||||||
|
|
||||||
|
- name: Collect release files
|
||||||
|
# forgejo-release uploads every file in a single directory, so gather the
|
||||||
|
# archives into one flat folder.
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/release
|
||||||
|
cp dist/linux/*.tar.gz dist/windows/*.zip dist/release/
|
||||||
|
|
||||||
|
- name: Attach assets to release
|
||||||
|
# Only runs for the release event; the release already exists, so this
|
||||||
|
# uploads the built archives to it. Skipped on workflow_dispatch.
|
||||||
|
if: ${{ github.event_name == 'release' }}
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: https://codeberg.org
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
release-dir: dist/release
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
override: true
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
# Keep shell scripts LF even on Windows checkouts so bash on Linux hosts
|
# Keep shell scripts LF even on Windows checkouts so bash on Linux hosts
|
||||||
# doesn't choke on trailing CRs (e.g. "set: pipefail: invalid parameter name").
|
# doesn't choke on trailing CRs (e.g. "set: pipefail: invalid parameter name").
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# CI workflow YAML embeds shell in `run:` blocks that Linux runners execute with
|
||||||
|
# bash, so keep these LF for the same reason as the shell scripts above.
|
||||||
|
.github/workflows/*.yml text eol=lf
|
||||||
|
.forgejo/workflows/*.yml text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Build the Linux (amd64/arm64) and Windows (amd64) binaries whenever a GitHub
|
||||||
|
# Release is published, then attach the packaged archives to that release.
|
||||||
|
#
|
||||||
|
# Everything runs inside golang:1.22-bookworm — the same base image as the
|
||||||
|
# repo Dockerfile — so the CGO/Fyne toolchain matches the local release builds.
|
||||||
|
# The Windows binary is cross-compiled with MinGW-w64 from the same Linux job,
|
||||||
|
# which is why no windows-latest runner is needed.
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# Allow a manual run (from the Actions tab) to smoke-test the build without
|
||||||
|
# publishing a release. Manual runs build the artifacts but upload nothing.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # required to create the release and upload assets
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.22-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install cross toolchain
|
||||||
|
# Mirrors the package list in the repo Dockerfile: native gcc + X11/GL
|
||||||
|
# headers for amd64, the aarch64 cross compiler with arm64 runtime libs,
|
||||||
|
# and the MinGW-w64 toolchain for the Windows GUI binary. zip packages
|
||||||
|
# the Windows archive.
|
||||||
|
run: |
|
||||||
|
dpkg --add-architecture arm64
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
libc6-dev-arm64-cross \
|
||||||
|
linux-libc-dev-arm64-cross \
|
||||||
|
gcc-mingw-w64-x86-64 \
|
||||||
|
binutils-mingw-w64-x86-64 \
|
||||||
|
pkg-config \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
xorg-dev \
|
||||||
|
libgl1-mesa-dev:arm64 \
|
||||||
|
libx11-dev:arm64 \
|
||||||
|
libxcursor-dev:arm64 \
|
||||||
|
libxrandr-dev:arm64 \
|
||||||
|
libxinerama-dev:arm64 \
|
||||||
|
libxi-dev:arm64 \
|
||||||
|
libxxf86vm-dev:arm64 \
|
||||||
|
zip
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
- name: Derive version
|
||||||
|
# For a release, use the tag without its leading "v" so the artifact
|
||||||
|
# names and the injected app version match the release (the release
|
||||||
|
# event still sets GITHUB_REF_TYPE=tag / GITHUB_REF_NAME=<tag>).
|
||||||
|
# Otherwise fall back to the version in source (handled by the build
|
||||||
|
# script).
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
ref="${GITHUB_REF_NAME:-}"
|
||||||
|
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
echo "value=${ref#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and package
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.value }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/ci-build-release.sh
|
||||||
|
scripts/ci-build-release.sh
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gosentry-release
|
||||||
|
path: |
|
||||||
|
dist/linux/*.tar.gz
|
||||||
|
dist/windows/*.zip
|
||||||
|
|
||||||
|
- name: Attach assets to release
|
||||||
|
# Only runs for the release event; the release already exists, so this
|
||||||
|
# just uploads the built archives to it. Skipped on workflow_dispatch.
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.event.release.tag_name }}
|
||||||
|
files: |
|
||||||
|
dist/linux/*.tar.gz
|
||||||
|
dist/windows/*.zip
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Persistent Go build cache for Docker-based release builds.
|
||||||
|
.gocache/
|
||||||
|
|
||||||
# Generated Windows resource compiled from packaging/windows/gosentry.rc.
|
# Generated Windows resource compiled from packaging/windows/gosentry.rc.
|
||||||
cmd/gosentry/*.syso
|
cmd/gosentry/*.syso
|
||||||
|
|
||||||
@@ -17,6 +20,10 @@ logs/
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
|
# Claude Code per-developer settings. The shared command in .claude/commands is
|
||||||
|
# tracked; locally granted tool permissions are not.
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
# GoodSync metadata. This is intentionally kept because the directory is local
|
# GoodSync metadata. This is intentionally kept because the directory is local
|
||||||
# to the user's file synchronization setup.
|
# to the user's file synchronization setup.
|
||||||
_gsdata_/
|
_gsdata_/
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# GoSentry — instructions for Claude Code
|
||||||
|
|
||||||
|
Cross-platform desktop scheduler (Go + Fyne GUI). Single process: GUI,
|
||||||
|
application service, scheduler, storage, and command runner in one binary.
|
||||||
|
|
||||||
|
## Read before changing code
|
||||||
|
|
||||||
|
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
|
||||||
|
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.
|
||||||
|
- [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/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
|
||||||
|
|
||||||
|
## Key rules (full list in STANDARDS.md)
|
||||||
|
|
||||||
|
- `src/app.Service` is the sole owner of job and runtime state; the UI reads it
|
||||||
|
through typed events, never through shared mutable state.
|
||||||
|
- User-facing errors go to `dialog.ShowError` or a History event — never a silent
|
||||||
|
`return`.
|
||||||
|
- Pure helpers get a unit test in the same package; fixes of severity ≥ medium get
|
||||||
|
a regression test.
|
||||||
|
- UI view constructors accept an injected `*app.Service`; `app.Open()` is called
|
||||||
|
only from `run.go`.
|
||||||
|
- Off-main-thread widget updates must go through `fyne.Do` (Fyne v2.7.4).
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
CGO is required — the Fyne GUI links native libraries. On Windows the toolchain
|
||||||
|
is MSYS2 UCRT64; the default shell environment here has CGO off, so set it
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path; $env:CGO_ENABLED = '1'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\test.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
which runs `go vet ./...` and `go test -race ./...`. Release binaries come from
|
||||||
|
`scripts\build-windows.bat` / `scripts/build-linux.sh` — see
|
||||||
|
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||||
|
|
||||||
|
## Repository conventions
|
||||||
|
|
||||||
|
- Commit directly to `main`; do not create feature branches.
|
||||||
|
- Notable changes get a [docs/CHANGELOG.md](docs/CHANGELOG.md) entry under the
|
||||||
|
current version.
|
||||||
|
- The window/taskbar icon comes from the `gosentry.ico` PE resource — regenerate
|
||||||
|
it from the PNGs whenever an icon changes, not just the embedded asset.
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="assets/logo/gosentry-logo-dark.svg">
|
||||||
|
<img src="assets/logo/gosentry-logo.svg" alt="GoSentry" width="420">
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
# GoSentry
|
# GoSentry
|
||||||
|
|
||||||
GoSentry is a cross-platform desktop scheduler. It provides a native GUI for
|
GoSentry is a cross-platform desktop scheduler. It provides a native GUI for
|
||||||
@@ -7,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>
|
||||||
|
|
||||||
@@ -18,20 +28,35 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
|||||||
- Job definitions stored in a clean, hand-editable `jobs.json`.
|
- Job definitions stored in a clean, hand-editable `jobs.json`.
|
||||||
- `@every` intervals and standard 5-field cron expressions.
|
- `@every` intervals and standard 5-field cron expressions.
|
||||||
- Manual and scheduled command runs.
|
- Manual and scheduled command runs.
|
||||||
- Parallel or sequential execution mode; configurable overlap policy (skip or queue).
|
- Parallel or sequential execution mode; overlap policy (skip or queue) set globally or per job.
|
||||||
|
- Run timeout, off by default, set globally or per job.
|
||||||
- Per-run `.log` files with stdout/stderr capture.
|
- Per-run `.log` files with stdout/stderr capture.
|
||||||
- Log cleanup by maximum file count and maximum age.
|
- Log cleanup by maximum file count and maximum age.
|
||||||
- Global pause/resume for scheduled job execution (manual runs remain available).
|
- Global pause/resume for scheduled job execution (manual runs remain available).
|
||||||
- Desktop notifications on job failure.
|
- Desktop notifications on job failure.
|
||||||
- Windows tray icon: left-click to show the window, right-click for the menu.
|
- Windows tray icon: left-click to show the window, right-click for the menu.
|
||||||
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
|
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
|
||||||
|
- Detailed or compact job list, and a system or branded theme; both are remembered.
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
GoSentry is built and tested on **Windows** and **Linux**:
|
||||||
|
|
||||||
|
| Platform | Status | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Windows | Supported | Tray icon, autostart shortcut (`.lnk`), desktop integration. |
|
||||||
|
| Linux | Supported | Autostart via XDG desktop entry; desktop integration on X11/Wayland. |
|
||||||
|
| macOS | Not supported | The Fyne GUI may build, but autostart and desktop integration are not implemented. |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Changelog](docs/CHANGELOG.md)
|
- [Changelog](docs/CHANGELOG.md) — record of notable changes by version
|
||||||
- [Roadmap](docs/ROADMAP.md)
|
- [Roadmap](docs/ROADMAP.md) — planned work larger than a single bug fix
|
||||||
- [Architecture](docs/ARCHITECTURE.md)
|
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
|
||||||
|
- [Standards](docs/STANDARDS.md) — quality rules and intentional behavior
|
||||||
- [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
|
||||||
|
- [Performance](docs/PERFORMANCE.md) — measured performance findings
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
@@ -42,17 +67,29 @@ portable application: moving the program folder also moves its configuration.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"jobs_dir": ".",
|
"jobs_file": "jobs.json",
|
||||||
"logs_dir": "logs",
|
"logs_dir": "logs",
|
||||||
"max_log_files": 100,
|
"max_log_files": 100,
|
||||||
"max_log_age_days": 30,
|
"max_log_age_days": 30,
|
||||||
"keep_running_in_tray": true,
|
"keep_running_in_tray": true,
|
||||||
"notify_on_failure": true,
|
"notify_on_failure": true,
|
||||||
"execution_mode": "parallel",
|
"execution_mode": "parallel",
|
||||||
"overlap_policy": "skip"
|
"overlap_policy": "skip",
|
||||||
|
"default_timeout_seconds": 0,
|
||||||
|
"theme": "gosentry",
|
||||||
|
"job_list_view": "detailed"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That is the file GoSentry writes on first run. `default_timeout_seconds` is the
|
||||||
|
run timeout applied to jobs that do not set their own; `0` means no timeout, and
|
||||||
|
it is written out even though it is zero, because a missing value and a
|
||||||
|
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
|
||||||
|
`theme` is `system` or `gosentry` (the branded teal/amber look), and
|
||||||
|
`job_list_view` is `detailed` or `compact` — both are remembered from the
|
||||||
|
choices made in the app. Keys left at their off value (`start_on_login`,
|
||||||
|
`paused`) are omitted until they are turned on.
|
||||||
|
|
||||||
`jobs.json` stores job definitions:
|
`jobs.json` stores job definitions:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -70,10 +107,15 @@ portable application: moving the program folder also moves its configuration.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`jobs_dir` is the directory GoSentry reads `jobs.json` from. The default `"."`
|
`jobs_file` is the file GoSentry reads job definitions from, file name included,
|
||||||
means the same folder as the executable. An absolute path can be used when jobs
|
so the file can be named anything. The default `"jobs.json"` is relative and
|
||||||
|
resolves to the executable's folder. An absolute path can be used when jobs
|
||||||
should live elsewhere, such as a shared network drive.
|
should live elsewhere, such as a shared network drive.
|
||||||
|
|
||||||
|
A `gosentry.json` from an earlier version that carries `jobs_dir` instead keeps
|
||||||
|
working: the directory is combined with `jobs.json` on load, and the file is
|
||||||
|
rewritten with `jobs_file`.
|
||||||
|
|
||||||
`logs_dir` is relative to the program folder when it does not start with a
|
`logs_dir` is relative to the program folder when it does not start with a
|
||||||
drive letter or `/`.
|
drive letter or `/`.
|
||||||
|
|
||||||
@@ -86,22 +128,55 @@ include the run timestamp and job name:
|
|||||||
|
|
||||||
## Schedules
|
## Schedules
|
||||||
|
|
||||||
Interval schedules using Go duration syntax:
|
GoSentry accepts two schedule forms: fixed `@every` intervals and standard
|
||||||
|
5-field cron expressions.
|
||||||
|
|
||||||
|
### `@every` intervals
|
||||||
|
|
||||||
|
Write `@every` followed by a [Go duration](https://pkg.go.dev/time#ParseDuration)
|
||||||
|
— a positive number with a unit suffix. Units can be combined in one value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@every 10s
|
@every 10s every 10 seconds
|
||||||
@every 5m
|
@every 5m every 5 minutes
|
||||||
@every 1h30m
|
@every 1h every hour
|
||||||
|
@every 1h30m every hour and a half (same as @every 90m)
|
||||||
|
@every 2h45m10s hours, minutes, and seconds combined
|
||||||
```
|
```
|
||||||
|
|
||||||
Standard 5-field cron expressions:
|
Supported units:
|
||||||
|
|
||||||
|
| Unit | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `ns` | nanoseconds |
|
||||||
|
| `us`, `µs` | microseconds |
|
||||||
|
| `ms` | milliseconds |
|
||||||
|
| `s` | seconds |
|
||||||
|
| `m` | minutes |
|
||||||
|
| `h` | hours |
|
||||||
|
|
||||||
|
`@every` does **not** support days, weeks, months, or years — those follow a
|
||||||
|
calendar, not a fixed interval. For “every day at 02:00”, “on the 1st of each
|
||||||
|
month”, or “once a year”, use a cron expression (below).
|
||||||
|
|
||||||
|
The scheduler checks due jobs once per second, so values shorter than `1s` are
|
||||||
|
accepted but will not fire faster than once a second.
|
||||||
|
|
||||||
|
### Cron expressions
|
||||||
|
|
||||||
|
Five fields: minute, hour, day-of-month, month, day-of-week.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
*/5 * * * * every five minutes
|
*/5 * * * * every five minutes
|
||||||
0 2 * * * every day at 02:00
|
0 2 * * * every day at 02:00
|
||||||
30 9 * * 1-5 weekdays at 09:30
|
30 9 * * 1-5 weekdays at 09:30
|
||||||
|
0 0 1 * * first day of every month at midnight
|
||||||
|
0 0 1 1 * every year on 1 January at midnight
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
|
||||||
|
`@monthly`, `@yearly` (and `@annually`, `@midnight`).
|
||||||
|
|
||||||
## Using The App
|
## Using The App
|
||||||
|
|
||||||
1. Start GoSentry.
|
1. Start GoSentry.
|
||||||
@@ -109,21 +184,36 @@ Standard 5-field cron expressions:
|
|||||||
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 storage directories, log cleanup limits, queue behavior, and notifications.
|
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
||||||
|
|
||||||
Changing `jobs_dir` in Settings saves the current job list to the new directory.
|
The **Jobs file** row picks the file itself: **Browse** lists `.json` files, and
|
||||||
|
a path can also be typed to name a file that does not exist yet. What Save does
|
||||||
|
depends on whether that file is already there:
|
||||||
|
|
||||||
|
- **The file exists** — its jobs are loaded and replace the current list, so
|
||||||
|
selecting a jobs file switches to it (another machine's file, a shared one on
|
||||||
|
a network drive). History records how many jobs were loaded and from where.
|
||||||
|
- **The file does not exist** — the current jobs are written to it, which is how
|
||||||
|
the jobs file is renamed or moved somewhere else.
|
||||||
|
|
||||||
|
Switching to a different jobs file is refused while a job is running, because
|
||||||
|
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
|
||||||
|
|
||||||
Two settings in the **Queue** group of the Settings tab control how simultaneous
|
Three settings in the **Queue** group of the Settings tab control how
|
||||||
and overlapping runs are handled.
|
simultaneous, overlapping, and over-long runs are handled.
|
||||||
|
|
||||||
**Execution mode** — applies when multiple jobs become due at the same tick:
|
**Execution mode** — applies when multiple jobs become due at the same tick:
|
||||||
|
|
||||||
@@ -132,19 +222,31 @@ and overlapping runs are handled.
|
|||||||
| `parallel` (default) | All due jobs start at the same time. |
|
| `parallel` (default) | All due jobs start at the same time. |
|
||||||
| `sequential` | Due jobs are started one after another, in the order they appear in the list. |
|
| `sequential` | Due jobs are started one after another, in the order they appear in the list. |
|
||||||
|
|
||||||
**Overlap policy** — applies when a job's next scheduled run fires while its
|
**Default overlap policy** — applies when a job's next scheduled run fires while
|
||||||
previous run is still active:
|
its previous run is still active:
|
||||||
|
|
||||||
| Value | Behaviour |
|
| Value | Behaviour |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| `skip` (default) | The new run is discarded; the running instance continues. |
|
| `skip` (default) | The new run is discarded; the running instance continues. |
|
||||||
| `queue` | The new run is held and starts immediately after the current run finishes. |
|
| `queue` | The new run is held and starts immediately after the current run finishes. |
|
||||||
|
|
||||||
|
**Default timeout (s)** — how long a run may take before it is killed. `0` (the
|
||||||
|
default) means no limit.
|
||||||
|
|
||||||
|
The last two are defaults: a job's own dialog has an **Overlap policy** and a
|
||||||
|
**Timeout (s)** field that override them. A job that overrides nothing follows
|
||||||
|
whatever the Settings tab says, so changing a default moves every such job with
|
||||||
|
it. In `jobs.json` an override is an `overlap_policy` or `timeout_seconds` key
|
||||||
|
on the job; absent means inherit. A `"timeout_seconds": 0` on a job is an
|
||||||
|
override too — it means that job has no timeout even when the global default
|
||||||
|
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
|
||||||
|
|
||||||
@@ -160,7 +262,7 @@ Linux:
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=GoSentry
|
Name=GoSentry
|
||||||
Exec=/opt/gosentry/gosentry-0.9.0-linux-amd64 --start-in-tray
|
Exec=/opt/gosentry/gosentry-<version>-linux-amd64 --start-in-tray
|
||||||
Terminal=false
|
Terminal=false
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,10 +303,15 @@ Known workaround:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
dist\windows\
|
dist\windows\
|
||||||
gosentry-0.9.0-windows-amd64.exe
|
gosentry-<version>-windows-amd64.exe
|
||||||
opengl32.dll
|
opengl32.dll
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Mesa's software OpenGL implementation lets the Fyne window start even when the
|
Mesa's software OpenGL implementation lets the Fyne window start even when the
|
||||||
VirtualBox/RDP driver does not provide usable OpenGL.
|
VirtualBox/RDP driver does not provide usable OpenGL.
|
||||||
|
|
||||||
|
## Development assistance
|
||||||
|
|
||||||
|
Parts of this project were developed with assistance from [Cursor](https://cursor.com/) AI (Composer agent)
|
||||||
|
and [Claude Code](https://claude.com/claude-code).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GoSentry — логотип v3</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Space+Grotesk:wght@500;600;700&family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--petrol:#0A4A58; --petrol-900:#04262E; --amber:#F7A80C;
|
||||||
|
--ink:#04252C; --paper:#F1F5F5; --line-l:rgba(4,37,44,.12);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--paper);color:var(--ink);
|
||||||
|
font-family:"Inter",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;padding:0 26px}
|
||||||
|
header{background:radial-gradient(120% 140% at 80% 0%,var(--petrol),var(--petrol-900));color:#fff;padding:50px 0 40px}
|
||||||
|
header .eyebrow{font-family:"Space Grotesk";font-weight:600;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:var(--amber);margin:0 0 12px}
|
||||||
|
header h1{font-family:"Space Grotesk";font-weight:700;font-size:clamp(24px,4vw,34px);margin:0 0 8px;letter-spacing:-.02em}
|
||||||
|
header p{margin:0;color:rgba(255,255,255,.72);max-width:62ch;font-size:15px}
|
||||||
|
|
||||||
|
section{border-bottom:1px solid var(--line-l);padding:40px 0}
|
||||||
|
.chead{display:flex;align-items:baseline;gap:14px;margin-bottom:6px;flex-wrap:wrap}
|
||||||
|
.num{font-family:"Space Grotesk";font-weight:700;color:var(--amber);font-size:14px}
|
||||||
|
.ctitle{font-family:"Space Grotesk";font-weight:700;font-size:19px;margin:0;letter-spacing:-.01em}
|
||||||
|
.cnote{font-size:14px;color:#4b666e;margin:0 0 22px;max-width:66ch}
|
||||||
|
|
||||||
|
/* wordmark engine */
|
||||||
|
.wm{font-weight:600;letter-spacing:-.03em;line-height:1;white-space:nowrap;display:inline-flex;align-items:baseline}
|
||||||
|
.wm .am{color:var(--amber)} .wm .pe{color:var(--petrol)}
|
||||||
|
.deep .wm .pe{color:#fff}
|
||||||
|
.dial{width:.68em;height:.68em;display:inline-block;transform:translateY(.02em);margin:0 .015em}
|
||||||
|
.f-space{font-family:"Space Grotesk"} .f-sora{font-family:"Sora"} .f-bric{font-family:"Bricolage Grotesque"}
|
||||||
|
|
||||||
|
.hero-stage{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.panel{border-radius:16px;min-height:170px;display:flex;align-items:center;justify-content:center;padding:30px;border:1px solid var(--line-l);position:relative}
|
||||||
|
.panel.light{background:#fff}
|
||||||
|
.panel.deep{background:var(--petrol-900);border-color:transparent}
|
||||||
|
.tag{position:absolute;top:12px;left:14px;font-family:"Space Grotesk";font-weight:600;font-size:10px;letter-spacing:.14em;color:rgba(4,37,44,.22);text-transform:uppercase}
|
||||||
|
.deep .tag{color:rgba(255,255,255,.3)}
|
||||||
|
.hero .wm{font-size:clamp(46px,11vw,82px)}
|
||||||
|
|
||||||
|
.row{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||||
|
.cell{background:#fff;border:1px solid var(--line-l);border-radius:14px;padding:26px 18px;text-align:center}
|
||||||
|
.cell .lbl{font-family:"Space Grotesk";font-weight:600;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:#5E7580;margin:0 0 16px}
|
||||||
|
.cell .wm{font-size:38px}
|
||||||
|
.rec{display:inline-block;font-family:"Space Grotesk";font-weight:700;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--amber);border:1px solid var(--amber);border-radius:5px;padding:2px 6px;margin-left:8px;vertical-align:1px}
|
||||||
|
|
||||||
|
.faces{display:grid;grid-template-columns:1fr;gap:12px}
|
||||||
|
.frow{background:#fff;border:1px solid var(--line-l);border-radius:14px;padding:20px 26px;display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||||
|
.frow .fn{font-family:"Space Grotesk";font-weight:600;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#5E7580;min-width:150px}
|
||||||
|
.frow .wm{font-size:clamp(34px,7vw,50px)}
|
||||||
|
|
||||||
|
.foot{padding:30px 0 60px;color:#5E7580;font-family:"Space Grotesk";font-weight:500;font-size:12.5px}
|
||||||
|
:focus-visible{outline:3px solid var(--amber);outline-offset:3px}
|
||||||
|
@media (max-width:760px){.hero-stage{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="eyebrow">GoSentry / logotype · v3</p>
|
||||||
|
<h1>Two-tone + деталь: «o» как циферблат</h1>
|
||||||
|
<p>В слове уже есть готовый кружок — буква «o» в «Go». Делаем из неё маленький циферблат: тема расписания и «дозора» встроена прямо в надпись, а не приклеена сбоку иконкой.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- HERO: recommended -->
|
||||||
|
<section class="hero"><div class="wrap">
|
||||||
|
<div class="chead"><span class="num">★</span><h3 class="ctitle">Рекомендую: Space Grotesk + стрелки</h3></div>
|
||||||
|
<p class="cnote">Янтарные «G» и циферблат-«o», петрол «Sentry». Циферблат со стрелками сразу читается как часы и держит характер даже мелко.</p>
|
||||||
|
<div class="hero-stage">
|
||||||
|
<div class="panel light"><span class="tag">light</span>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="panel deep"><span class="tag">dark</span>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<!-- dial intensity -->
|
||||||
|
<section><div class="wrap">
|
||||||
|
<div class="chead"><span class="num">01</span><h3 class="ctitle">Насколько «часы» показывать</h3></div>
|
||||||
|
<p class="cnote">Три уровня детали в «o». Слева — обычная буква (деталь почти незаметна), в центре — одна засечка на 12 (тонкий намёк на дозор/время), справа — полноценные стрелки.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell"><p class="lbl">plain — просто «o»</p>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="cell"><p class="lbl">tick — засечка на 12</p>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 19 L50 32" stroke="#F7A80C" stroke-width="11" stroke-linecap="round"/><circle cx="50" cy="50" r="5" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="cell"><p class="lbl">hands — стрелки <span class="rec">рек.</span></p>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<!-- face choice in context -->
|
||||||
|
<section><div class="wrap">
|
||||||
|
<div class="chead"><span class="num">02</span><h3 class="ctitle">Шрифт — теперь разница видна</h3></div>
|
||||||
|
<p class="cnote">Одна и та же деталь, три гарнитуры крупно. Смотри на «G», «S», «t», «y» — там весь характер. Space Grotesk — техничный и приметный; Sora — гладкий, нейтральный; Bricolage — самый выразительный, «человечный».</p>
|
||||||
|
<div class="faces">
|
||||||
|
<div class="frow"><span class="fn">Space Grotesk ★</span>
|
||||||
|
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="frow"><span class="fn">Sora</span>
|
||||||
|
<span class="wm f-sora"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="frow"><span class="fn">Bricolage</span>
|
||||||
|
<span class="wm f-bric"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<div class="foot"><div class="wrap">Выбери уровень детали (plain / tick / hands) + шрифт — переведу «o»-циферблат и весь логотип в кривые и отдам чистый SVG (light/dark) + PNG.</div></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# GoSentry logo
|
||||||
|
|
||||||
|
Recommended wordmark variant from [`../gosentry-logo.html`](../gosentry-logo.html):
|
||||||
|
**Space Grotesk SemiBold** with an amber **G**, a **clock-dial "o"** (ring + hands),
|
||||||
|
and a petrol **"Sentry"**. The dial sits exactly in the `o` slot, so the schedule /
|
||||||
|
"watch" theme lives inside the letters instead of a bolt-on icon.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
| token | hex | use |
|
||||||
|
|--------|-----------|-----------------------------|
|
||||||
|
| amber | `#F7A80C` | `G`, dial ring + hands |
|
||||||
|
| petrol | `#0A4A58` | `Sentry` (light background) |
|
||||||
|
| white | `#FFFFFF` | `Sentry` (dark background) |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Vector (self-contained — glyphs are outlined to paths, no font required):
|
||||||
|
|
||||||
|
- `gosentry-logo.svg` — transparent, petrol `Sentry` (for light backgrounds)
|
||||||
|
- `gosentry-logo-dark.svg` — transparent, white `Sentry` (for dark backgrounds)
|
||||||
|
- `gosentry-logo-mono.svg` — single-color petrol
|
||||||
|
|
||||||
|
Raster (transparent PNG, aspect ≈ 4326×1034 ≈ 4.18:1):
|
||||||
|
|
||||||
|
- `gosentry-logo-{256,512,1024,2048}.png` — petrol `Sentry`
|
||||||
|
- `gosentry-logo-dark-{256,512,1024,2048}.png` — white `Sentry`
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
Requires `fonttools` and `matplotlib`, plus the Space Grotesk variable font
|
||||||
|
(SIL OFL) instanced to weight 600 as `SpaceGrotesk-600.ttf`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python gen_logo.py # writes SVGs into ./out
|
||||||
|
python raster.py # writes PNGs into ./out
|
||||||
|
```
|
||||||
|
|
||||||
|
`gen_logo.py` (SVG) and `raster.py` (PNG) share the same layout + dial geometry,
|
||||||
|
so both outputs stay identical. Space Grotesk is licensed under the SIL Open Font
|
||||||
|
License; outlining its glyphs into a logo is permitted.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate GoSentry wordmark logo assets (recommended variant:
|
||||||
|
Space Grotesk SemiBold + amber 'G' + clock-dial 'o' with hands + 'Sentry')."""
|
||||||
|
import os
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
from fontTools.pens.svgPathPen import SVGPathPen
|
||||||
|
from fontTools.pens.transformPen import TransformPen
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUT = os.path.join(HERE, "out")
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
AMBER = "#F7A80C"
|
||||||
|
PETROL = "#0A4A58"
|
||||||
|
WHITE = "#FFFFFF"
|
||||||
|
|
||||||
|
LS = -30 # letter-spacing -0.03em at 1000 upm
|
||||||
|
|
||||||
|
f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
|
||||||
|
cmap = f.getBestCmap()
|
||||||
|
hmtx = f["hmtx"]
|
||||||
|
gs = f.getGlyphSet()
|
||||||
|
|
||||||
|
def glyph_path(ch, dx):
|
||||||
|
"""Return SVG path 'd' for ch, shifted by dx in font units (y still up)."""
|
||||||
|
g = cmap[ord(ch)]
|
||||||
|
pen = SVGPathPen(gs)
|
||||||
|
tpen = TransformPen(pen, (1, 0, 0, 1, dx, 0))
|
||||||
|
gs[g].draw(tpen)
|
||||||
|
return pen.getCommands(), hmtx[g][0]
|
||||||
|
|
||||||
|
def o_metrics():
|
||||||
|
g = cmap[ord("o")]
|
||||||
|
bp = BoundsPen(gs); gs[g].draw(bp)
|
||||||
|
xmin,ymin,xmax,ymax = bp.bounds
|
||||||
|
return hmtx[g][0], xmin, ymin, xmax, ymax
|
||||||
|
|
||||||
|
# ---- layout the wordmark ---------------------------------------------------
|
||||||
|
x = 0.0
|
||||||
|
G_d, adv = glyph_path("G", x); x += adv + LS
|
||||||
|
|
||||||
|
# dial occupies the 'o' advance slot
|
||||||
|
o_adv, oxmin, oymin, oxmax, oymax = o_metrics()
|
||||||
|
o_left = x
|
||||||
|
cx = o_left + (oxmin + oxmax) / 2.0
|
||||||
|
cy = (oymin + oymax) / 2.0
|
||||||
|
R = ((oxmax - oxmin) + (oymax - oymin)) / 4.0 # avg radius, matches the 'o'
|
||||||
|
x += o_adv + LS
|
||||||
|
|
||||||
|
sentry_d = []
|
||||||
|
for ch in "Sentry":
|
||||||
|
d, adv = glyph_path(ch, x)
|
||||||
|
sentry_d.append(d)
|
||||||
|
x += adv + LS
|
||||||
|
x -= LS # no trailing letter-spacing
|
||||||
|
SENTRY_D = " ".join(sentry_d)
|
||||||
|
|
||||||
|
# ---- dial geometry (matched to the 'o', proportions from the HTML mock) ----
|
||||||
|
SW = R * 0.30 # ring stroke width
|
||||||
|
Rmid = R - SW / 2.0 # centreline radius of ring
|
||||||
|
HW = R * 0.21 # hand width
|
||||||
|
hour_len = R * 0.54 # 12 o'clock hand
|
||||||
|
min_len = R * 0.46 # ~4 o'clock hand
|
||||||
|
min_ang = 62 # degrees clockwise from 12
|
||||||
|
import math
|
||||||
|
mx = cx + min_len * math.sin(math.radians(min_ang))
|
||||||
|
my = cy + min_len * math.cos(math.radians(min_ang)) # font-up: +y is up
|
||||||
|
cap = R * 0.14
|
||||||
|
|
||||||
|
dial = f'''<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{Rmid:.1f}" fill="none" stroke="{AMBER}" stroke-width="{SW:.1f}"/>
|
||||||
|
<path d="M{cx:.1f} {cy:.1f} L{cx:.1f} {cy+hour_len:.1f}" stroke="{AMBER}" stroke-width="{HW:.1f}" stroke-linecap="round"/>
|
||||||
|
<path d="M{cx:.1f} {cy:.1f} L{mx:.1f} {my:.1f}" stroke="{AMBER}" stroke-width="{HW:.1f}" stroke-linecap="round"/>
|
||||||
|
<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{cap:.1f}" fill="{AMBER}"/>'''
|
||||||
|
|
||||||
|
# ---- overall bounds (font units, y up) -------------------------------------
|
||||||
|
bp = BoundsPen(gs)
|
||||||
|
xall = 0.0
|
||||||
|
gG = cmap[ord("G")]; gs[gG].draw(TransformPen(bp,(1,0,0,1,0,0)))
|
||||||
|
xall += hmtx[gG][0] + LS + o_adv + LS
|
||||||
|
for ch in "Sentry":
|
||||||
|
g = cmap[ord(ch)]
|
||||||
|
gs[g].draw(TransformPen(bp,(1,0,0,1,xall,0)))
|
||||||
|
xall += hmtx[g][0] + LS
|
||||||
|
bx0,by0,bx1,by1 = bp.bounds
|
||||||
|
# include the dial extents
|
||||||
|
bx0 = min(bx0, cx-R-SW/2); bx1 = max(bx1, cx+R+SW/2)
|
||||||
|
by0 = min(by0, cy-R-SW/2); by1 = max(by1, cy+R+SW/2)
|
||||||
|
|
||||||
|
PAD = 60
|
||||||
|
W = (bx1 - bx0) + 2*PAD
|
||||||
|
H = (by1 - by0) + 2*PAD
|
||||||
|
# transform: font(x,y up) -> screen: translate then flip y
|
||||||
|
tx = PAD - bx0
|
||||||
|
ty = PAD + by1
|
||||||
|
transform = f"matrix(1 0 0 -1 {tx:.2f} {ty:.2f})"
|
||||||
|
|
||||||
|
def svg(sentry_color, bg=None, name=""):
|
||||||
|
bgrect = f'<rect width="{W:.1f}" height="{H:.1f}" fill="{bg}"/>\n' if bg else ""
|
||||||
|
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:.1f} {H:.1f}" role="img" aria-label="GoSentry">
|
||||||
|
{bgrect}<g transform="{transform}">
|
||||||
|
<path d="{G_d}" fill="{AMBER}"/>
|
||||||
|
<path d="{SENTRY_D}" fill="{sentry_color}"/>
|
||||||
|
{dial}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
|
||||||
|
variants = {
|
||||||
|
"gosentry-logo.svg": svg(PETROL), # light bg, transparent
|
||||||
|
"gosentry-logo-dark.svg": svg(WHITE), # dark bg, transparent
|
||||||
|
"gosentry-logo-onlight.svg": svg(PETROL, bg="#FFFFFF"),
|
||||||
|
"gosentry-logo-ondark.svg": svg(WHITE, bg="#04262E"),
|
||||||
|
"gosentry-logo-mono.svg": svg(PETROL).replace(AMBER, PETROL), # single-colour petrol
|
||||||
|
}
|
||||||
|
for fn, data in variants.items():
|
||||||
|
with open(os.path.join(OUT, fn), "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
print("wrote", fn)
|
||||||
|
print("viewBox %.1f x %.1f" % (W, H))
|
||||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
|
||||||
|
<g transform="matrix(1 0 0 -1 8.00 774.00)">
|
||||||
|
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#F7A80C"/>
|
||||||
|
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#FFFFFF"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#F7A80C" stroke-width="77.8"/>
|
||||||
|
<path d="M937.5 247.0 L937.5 387.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<path d="M937.5 247.0 L1042.8 303.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="36.3" fill="#F7A80C"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
|
||||||
|
<g transform="matrix(1 0 0 -1 8.00 774.00)">
|
||||||
|
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#0A4A58"/>
|
||||||
|
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#0A4A58"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#0A4A58" stroke-width="77.8"/>
|
||||||
|
<path d="M937.5 247.0 L937.5 387.0" stroke="#0A4A58" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<path d="M937.5 247.0 L1042.8 303.0" stroke="#0A4A58" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="36.3" fill="#0A4A58"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
|
||||||
|
<g transform="matrix(1 0 0 -1 8.00 774.00)">
|
||||||
|
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#F7A80C"/>
|
||||||
|
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#0A4A58"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#F7A80C" stroke-width="77.8"/>
|
||||||
|
<path d="M937.5 247.0 L937.5 387.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<path d="M937.5 247.0 L1042.8 303.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="937.5" cy="247.0" r="36.3" fill="#F7A80C"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rasterize the GoSentry wordmark to PNG at several widths, reusing the
|
||||||
|
same font outlines + dial geometry as gen_logo.py (no SVG rasterizer needed)."""
|
||||||
|
import os, math
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.path import Path
|
||||||
|
from matplotlib.patches import PathPatch, Circle
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
from fontTools.pens.basePen import BasePen
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
from fontTools.pens.transformPen import TransformPen
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
|
||||||
|
AMBER, PETROL, WHITE = "#F7A80C", "#0A4A58", "#FFFFFF"
|
||||||
|
LS = -30
|
||||||
|
|
||||||
|
f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
|
||||||
|
cmap, hmtx, gs = f.getBestCmap(), f["hmtx"], f.getGlyphSet()
|
||||||
|
|
||||||
|
class MplPen(BasePen):
|
||||||
|
def __init__(self, glyphSet):
|
||||||
|
super().__init__(glyphSet); self.v=[]; self.c=[]
|
||||||
|
def _moveTo(self,p): self.v.append(p); self.c.append(Path.MOVETO)
|
||||||
|
def _lineTo(self,p): self.v.append(p); self.c.append(Path.LINETO)
|
||||||
|
def _curveToOne(self,p1,p2,p3):
|
||||||
|
self.v += [p1,p2,p3]; self.c += [Path.CURVE4]*3
|
||||||
|
def _qCurveToOne(self,p1,p2):
|
||||||
|
self.v += [p1,p2]; self.c += [Path.CURVE3]*2
|
||||||
|
def _closePath(self):
|
||||||
|
self.v.append((0,0)); self.c.append(Path.CLOSEPOLY)
|
||||||
|
|
||||||
|
def glyph_mplpath(ch, dx):
|
||||||
|
pen = MplPen(gs)
|
||||||
|
gs[cmap[ord(ch)]].draw(TransformPen(pen,(1,0,0,1,dx,0)))
|
||||||
|
return Path(pen.v, pen.c), hmtx[cmap[ord(ch)]][0]
|
||||||
|
|
||||||
|
# layout
|
||||||
|
x=0.0
|
||||||
|
paths_amber=[]; paths_petrol=[]
|
||||||
|
p,adv = glyph_mplpath("G",x); paths_amber.append(p); x+=adv+LS
|
||||||
|
og=cmap[ord("o")]; bp=BoundsPen(gs); gs[og].draw(bp)
|
||||||
|
oxmin,oymin,oxmax,oymax=bp.bounds; o_adv=hmtx[og][0]
|
||||||
|
cx=x+(oxmin+oxmax)/2; cy=(oymin+oymax)/2
|
||||||
|
R=((oxmax-oxmin)+(oymax-oymin))/4
|
||||||
|
x+=o_adv+LS
|
||||||
|
for ch in "Sentry":
|
||||||
|
p,adv=glyph_mplpath(ch,x); paths_petrol.append(p); x+=adv+LS
|
||||||
|
x-=LS
|
||||||
|
|
||||||
|
SW=R*0.30; Rmid=R-SW/2; HW=R*0.21
|
||||||
|
hour_len=R*0.54; min_len=R*0.46; ang=math.radians(62)
|
||||||
|
mx=cx+min_len*math.sin(ang); my=cy+min_len*math.cos(ang); cap=R*0.14
|
||||||
|
|
||||||
|
# bounds
|
||||||
|
allb=BoundsPen(gs); gs[cmap[ord('G')]].draw(allb)
|
||||||
|
xx=hmtx[cmap[ord('G')]][0]+LS+o_adv+LS
|
||||||
|
for ch in "Sentry":
|
||||||
|
gs[cmap[ord(ch)]].draw(TransformPen(allb,(1,0,0,1,xx,0))); xx+=hmtx[cmap[ord(ch)]][0]+LS
|
||||||
|
bx0,by0,bx1,by1=allb.bounds
|
||||||
|
bx0=min(bx0,cx-R-SW/2); bx1=max(bx1,cx+R+SW/2)
|
||||||
|
by0=min(by0,cy-R-SW/2); by1=max(by1,cy+R+SW/2)
|
||||||
|
PAD=60
|
||||||
|
X0,X1=bx0-PAD,bx1+PAD; Y0,Y1=by0-PAD,by1+PAD
|
||||||
|
W=X1-X0; H=Y1-Y0
|
||||||
|
|
||||||
|
def render(path_png, width_px, sentry_color, bg=None, mono=False):
|
||||||
|
dpi=100
|
||||||
|
fw=width_px/dpi; fh=fw*H/W
|
||||||
|
fig=plt.figure(figsize=(fw,fh),dpi=dpi)
|
||||||
|
ax=fig.add_axes([0,0,1,1]); ax.set_xlim(X0,X1); ax.set_ylim(Y0,Y1)
|
||||||
|
ax.set_aspect('equal'); ax.axis('off')
|
||||||
|
if bg: fig.patch.set_facecolor(bg); ax.set_facecolor(bg)
|
||||||
|
else: fig.patch.set_alpha(0)
|
||||||
|
amberc = sentry_color if mono else AMBER
|
||||||
|
for p in paths_amber: ax.add_patch(PathPatch(p,facecolor=amberc,edgecolor='none',antialiased=True))
|
||||||
|
for p in paths_petrol: ax.add_patch(PathPatch(p,facecolor=sentry_color,edgecolor='none',antialiased=True))
|
||||||
|
pt_per_unit = fw/W*72
|
||||||
|
ax.add_patch(Circle((cx,cy),Rmid,fill=False,edgecolor=amberc,linewidth=SW*pt_per_unit))
|
||||||
|
for (ex,ey) in [(cx,cy+hour_len),(mx,my)]:
|
||||||
|
ax.add_line(Line2D([cx,ex],[cy,ey],color=amberc,linewidth=HW*pt_per_unit,
|
||||||
|
solid_capstyle='round'))
|
||||||
|
ax.add_patch(Circle((cx,cy),cap,facecolor=amberc,edgecolor='none'))
|
||||||
|
fig.savefig(path_png,dpi=dpi,transparent=(bg is None))
|
||||||
|
plt.close(fig)
|
||||||
|
print("wrote",os.path.basename(path_png))
|
||||||
|
|
||||||
|
for w in (256,512,1024,2048):
|
||||||
|
render(os.path.join(OUT,f"gosentry-logo-{w}.png"),w,PETROL)
|
||||||
|
render(os.path.join(OUT,f"gosentry-logo-dark-{w}.png"),w,WHITE)
|
||||||
|
render(os.path.join(OUT,"gosentry-logo-onlight-1024.png"),1024,PETROL,bg="#FFFFFF")
|
||||||
|
render(os.path.join(OUT,"gosentry-logo-ondark-1024.png"),1024,WHITE,bg="#04262E")
|
||||||
|
render(os.path.join(OUT,"gosentry-logo-mono-1024.png"),1024,PETROL,mono=True)
|
||||||
|
print(f"aspect {W:.0f}x{H:.0f}")
|
||||||
@@ -17,7 +17,8 @@ src/
|
|||||||
storage/ JSON persistence (gosentry.json, jobs.json)
|
storage/ JSON persistence (gosentry.json, jobs.json)
|
||||||
platform/
|
platform/
|
||||||
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
|
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
|
||||||
desktop/ display-scale helper (Linux only)
|
desktop/ desktop entry + icon under XDG data home (Linux only)
|
||||||
|
filemanager/ open a folder in the desktop file manager
|
||||||
winproc/ hidden-window startup flags (Windows only)
|
winproc/ hidden-window startup flags (Windows only)
|
||||||
ui/ Fyne windows, tabs, and dialogs; reads service via Events
|
ui/ Fyne windows, tabs, and dialogs; reads service via Events
|
||||||
```
|
```
|
||||||
@@ -27,20 +28,20 @@ src/
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
user["Desktop user"]
|
user["Desktop user"]
|
||||||
ui["src/ui\nFyne windows, tabs, dialogs"]
|
ui["src/ui - Fyne windows, tabs, dialogs"]
|
||||||
svc["src/app Service\nsole owner of job + runtime state"]
|
svc["src/app Service - sole owner of job + runtime state"]
|
||||||
store["src/storage Store\nJSON config and jobs"]
|
store["src/storage Store - JSON config and jobs"]
|
||||||
sched["src/scheduler Scheduler\npure timing loop"]
|
sched["src/scheduler Scheduler - pure timing loop"]
|
||||||
runner["src/runner\nshell command execution"]
|
runner["src/runner - shell command execution"]
|
||||||
autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"]
|
autostart["src/platform/autostart Manager - Windows shortcut / Linux XDG"]
|
||||||
config["gosentry.json\napplication settings"]
|
config["gosentry.json - application settings"]
|
||||||
jobs["jobs.json\njob definitions"]
|
jobs["jobs.json - job definitions"]
|
||||||
logs["logs_dir\nper-run command output logs"]
|
logs["logs_dir - per-run command output logs"]
|
||||||
shell["Platform shell\ncmd.exe /C or sh -c"]
|
shell["Platform shell - cmd.exe /C or sh -c"]
|
||||||
|
|
||||||
user -->|"edits jobs, settings, runs commands"| ui
|
user -->|"edits jobs, settings, runs commands"| ui
|
||||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, …"| 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
|
||||||
|
|
||||||
@@ -50,20 +51,81 @@ flowchart LR
|
|||||||
runner -->|"execute command"| shell
|
runner -->|"execute command"| shell
|
||||||
runner -->|"write stdout/stderr log"| logs
|
runner -->|"write stdout/stderr log"| logs
|
||||||
runner -->|"RunRecord"| svc
|
runner -->|"RunRecord"| svc
|
||||||
svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui
|
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
|
||||||
ui -->|"display jobs, history, status"| user
|
ui -->|"display jobs, history, status"| user
|
||||||
|
|
||||||
ui -->|"SetAutostart, AutostartStatus"| autostart
|
|
||||||
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 first 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).
|
||||||
|
|
||||||
@@ -74,6 +136,17 @@ flowchart LR
|
|||||||
`Event`. The UI's observer receives the event and refreshes the relevant
|
`Event`. The UI's observer receives the event and refreshes the relevant
|
||||||
widget on the main thread via `fyne.Do`.
|
widget on the main thread via `fyne.Do`.
|
||||||
|
|
||||||
|
`UpdateSettings` has one extra step: when the configured jobs file changes
|
||||||
|
and a file already exists at the new path, that file is authoritative. The
|
||||||
|
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
||||||
|
map, schedule cache, and next-run times around it, applies the statistics
|
||||||
|
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
|
||||||
|
`JobChanged`. A path with no file behind it receives the current jobs instead.
|
||||||
|
Adoption drops all runtime state, so it is refused while a job is running.
|
||||||
|
Reading the new file and seeding its statistics both happen before `mu` is
|
||||||
|
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
|
||||||
|
running-job check is re-evaluated under the lock before anything is replaced.
|
||||||
|
|
||||||
3. Scheduled run:
|
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
|
||||||
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
|
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
|
||||||
@@ -87,22 +160,29 @@ flowchart LR
|
|||||||
|
|
||||||
5. Command execution:
|
5. Command execution:
|
||||||
`runner.RunJob` builds the platform-specific invocation, executes the
|
`runner.RunJob` builds the platform-specific invocation, executes the
|
||||||
command through the platform shell, captures stdout and stderr, writes one
|
command through the platform shell under the caller-supplied timeout, captures
|
||||||
timestamped `.log` file, and returns a `domain.RunRecord` containing
|
stdout and stderr, writes one timestamped `.log` file, and returns a
|
||||||
|
`domain.RunRecord` containing
|
||||||
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
|
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
|
||||||
fire-and-forget jobs it measures launch latency — the time to spawn the
|
fire-and-forget jobs it measures launch latency — the time to spawn the
|
||||||
process — since there is no exit to wait for).
|
process — since there is no exit to wait for).
|
||||||
|
|
||||||
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
|
||||||
|
clears the table (aggregate stats in the details panel are still seeded from
|
||||||
|
log files).
|
||||||
|
|
||||||
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
|
||||||
@@ -125,6 +205,32 @@ in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
|
|||||||
`executeRun` drains the counter by starting one deferred run per completion until
|
`executeRun` drains the counter by starting one deferred run per completion until
|
||||||
`PendingRuns` reaches zero.
|
`PendingRuns` reaches zero.
|
||||||
|
|
||||||
|
### Per-job command timeout
|
||||||
|
|
||||||
|
`domain.Job` carries a `TimeoutSeconds *int` field
|
||||||
|
(`json:"timeout_seconds,omitempty"`), following the same inherit pattern as the
|
||||||
|
overlap policy. It is a **pointer** because the setting has three states that
|
||||||
|
must stay distinguishable on disk:
|
||||||
|
|
||||||
|
| `Job.TimeoutSeconds` | jobs.json | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `nil` | field absent | inherit `Config.DefaultTimeoutSeconds` |
|
||||||
|
| `0` | `"timeout_seconds": 0` | no timeout, does **not** inherit |
|
||||||
|
| `> 0` | `"timeout_seconds": 45` | per-job limit in seconds |
|
||||||
|
|
||||||
|
The global `Config.DefaultTimeoutSeconds` (default **0**, i.e. no timeout) is
|
||||||
|
written unconditionally — no `omitempty` — for the same reason: `0` there is a
|
||||||
|
deliberate choice, not a missing value, and `storage.loadOrCreateConfig` must not
|
||||||
|
normalize it away. `app.Service.effectiveTimeout`
|
||||||
|
resolves the effective duration under `mu` and `startRunLocked` snapshots it into
|
||||||
|
`runEnv.timeout`. `runner.RunJob(ctx, job, trigger, logsDir, timeout)` takes the
|
||||||
|
resolved duration as an argument, so the runner stays ignorant of the global
|
||||||
|
config: a positive duration applies the timeout via `context.WithTimeout` and
|
||||||
|
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
||||||
|
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
|
||||||
|
built on `context.Background()` instead — neither the timeout nor app shutdown
|
||||||
|
applies to them — and so measure launch latency only.
|
||||||
|
|
||||||
### Run-time statistics
|
### Run-time statistics
|
||||||
|
|
||||||
`domain.JobRuntime` holds a rolling aggregate updated after each run:
|
`domain.JobRuntime` holds a rolling aggregate updated after each run:
|
||||||
@@ -134,8 +240,10 @@ in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
|
|||||||
| `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
|
||||||
@@ -150,6 +258,11 @@ accumulated run history immediately after a restart.
|
|||||||
Older log files that pre-date the `duration` header are tolerated: the run is
|
Older log files that pre-date the `duration` header are tolerated: the run is
|
||||||
counted but the timing is skipped.
|
counted but the timing is skipped.
|
||||||
|
|
||||||
|
`JobRuntime.Logs` (per-run `RunRecord` entries shown in the History tab) is
|
||||||
|
**session-only**: it is not written to `jobs.json` and is not rebuilt from
|
||||||
|
`.log` files on startup. Log files on disk feed aggregate counters via
|
||||||
|
`SeedStats` only. See [STANDARDS.md](STANDARDS.md).
|
||||||
|
|
||||||
### Persisted global pause
|
### Persisted global pause
|
||||||
|
|
||||||
`domain.Config` carries a `Paused bool` field (`json:"paused,omitempty"`).
|
`domain.Config` carries a `Paused bool` field (`json:"paused,omitempty"`).
|
||||||
@@ -161,11 +274,71 @@ the moment the window opens.
|
|||||||
|
|
||||||
### `jobs_view.go` file structure
|
### `jobs_view.go` file structure
|
||||||
|
|
||||||
`src/ui/jobs_view.go` is split across three files to stay within the ~250-line
|
The size guideline for a file in this project is ~250 lines; up to 20% over
|
||||||
size guideline:
|
(~300 lines) is acceptable. `src/ui/jobs_view.go` is split across six files
|
||||||
|
along these seams:
|
||||||
|
|
||||||
| 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` |
|
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
||||||
|
|
||||||
|
The widgets hold no job state of their own: they read `jobsViewState`, which is
|
||||||
|
the only thing that reads the Service. The **selection is a job ID, not a row
|
||||||
|
index.** Every path that changes the job list replaces the state's snapshot —
|
||||||
|
create, delete, and edit from this view's own handlers, adopting a different
|
||||||
|
jobs file from the Service, which the view only learns about through the refresh
|
||||||
|
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
|
||||||
|
job now sits there, so the details pane would describe one job while the list
|
||||||
|
highlighted another. Rows are derived from the ID at render time
|
||||||
|
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
|
||||||
|
list's highlight at the selected job.
|
||||||
|
|
||||||
|
### `settings_view.go` file structure
|
||||||
|
|
||||||
|
`src/ui/settings_view.go` is split across four files the same way, once its
|
||||||
|
own size passed the guideline:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `settings_view.go` | `settingsView` — thin entry point; theme label translation helpers |
|
||||||
|
| `settings_view_form.go` | `buildSettingsForm` — widget construction, save, load, cancel, and defaults handlers |
|
||||||
|
| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row |
|
||||||
|
| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) |
|
||||||
|
|
||||||
|
### `operations.go` file structure
|
||||||
|
|
||||||
|
`src/app/operations.go` is split across four files along the public API,
|
||||||
|
locked helpers, and pure validation seams:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `operations.go` | Job mutators (`CreateJob` … `SetEnabled`); shared constants (`maxJobLogs`, `timestampLayout`, `errJobNotFound`) |
|
||||||
|
| `operations_settings.go` | Config mutators — `SetGlobalPause`, `SetJobListView`, `ShouldNotifyOnFailure`, `UpdateSettings` |
|
||||||
|
| `operations_locked.go` | `*Locked` state helpers (`refreshNextRunLocked` … `nextIDLocked`); `prependLog`, `uiRecord` |
|
||||||
|
| `operations_validate.go` | Pure validators and normalizers — `normalizeJob`, `validateJob`, `hasFileName`, `validateConfig` |
|
||||||
|
|
||||||
|
### `store.go` file structure
|
||||||
|
|
||||||
|
`src/storage/store.go` is split across three files. Path resolution for the
|
||||||
|
executable directory lives in `paths.go`; config-relative path resolution stays
|
||||||
|
with the store API:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `store.go` | `Store` struct, `OpenStore`, `PeekKeepRunningInTray`, save API, `ResolveConfiguredPath`, `applyConfigPaths`, atomic JSON writes |
|
||||||
|
| `store_config.go` | `loadOrCreateConfig` — config load, defaults, and migration shims |
|
||||||
|
| `store_jobs.go` | `LoadJobsFile`, `loadOrCreateJobs`, `normalizeJobs`, sample jobs, platform-specific demo commands |
|
||||||
|
|
||||||
|
### `history_view.go` file structure
|
||||||
|
|
||||||
|
`src/ui/history_view.go` is split across two files:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `history_view_columns.go` | Pure column-width helpers — `textWidth` through `historyColumnWidths`, sample sets, `historyContentValues` |
|
||||||
|
| `history_view.go` | `historyLog`, `historyHeader`, `newHistoryView`, cell text, `newEvent`, `logFileName` |
|
||||||
|
|||||||
@@ -2,6 +2,530 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
|
## 1.0.4 - 2026-08-07
|
||||||
|
|
||||||
|
**The remaining over-the-guideline source files are split; the roadmap is
|
||||||
|
restructured around what actually blocks each item.**
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- **`ROADMAP.md`** — reorganized into Features, Platform, Fyne/UI, and
|
||||||
|
Maintenance. The over-the-guideline file-split item is closed; each deferred
|
||||||
|
UI item now states whether it is blocked on a missing Fyne API, on UX
|
||||||
|
(shippable today but deferred), or on maintenance timing.
|
||||||
|
- **`ARCHITECTURE.md`** — documents the new file layouts for `operations.go`,
|
||||||
|
`store.go`, `history_view.go`, and `settings_view.go`; the size guideline
|
||||||
|
now states the 20% tolerance explicitly; `settings_view` is recorded as
|
||||||
|
four files, not three.
|
||||||
|
|
||||||
|
**Internal:**
|
||||||
|
|
||||||
|
- Four source files over the ~300-line ceiling (250 + 20%) were split in one
|
||||||
|
pass: `operations.go` into job mutators, config mutators
|
||||||
|
(`operations_settings.go`), `*Locked` helpers (`operations_locked.go`), and
|
||||||
|
pure validators (`operations_validate.go`); `store.go` into the store API,
|
||||||
|
config load (`store_config.go`), and jobs load (`store_jobs.go`);
|
||||||
|
`history_view.go` into column-width helpers (`history_view_columns.go`) and
|
||||||
|
the table widget; and `settings_view.go` into a thin entry point plus
|
||||||
|
`settings_view_form.go` for field construction and save/load handlers.
|
||||||
|
Mechanical moves only — every file is now within the ceiling. `run.go` and
|
||||||
|
`service.go` stay as-is.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**The window opens at the size it asks for, and the Jobs divider can be
|
||||||
|
dragged.**
|
||||||
|
|
||||||
|
**Window:**
|
||||||
|
|
||||||
|
- **The window opens at 1024×660 and can now be dragged narrower than it opens.**
|
||||||
|
Fyne treats the assembled content's minimum size as a hard floor over the
|
||||||
|
requested size, and two widgets in Settings pushed that minimum past 1024 px:
|
||||||
|
a fixed width applied to seven controls that the layout already stretched, and
|
||||||
|
the read-only config path, which grew the whole tab with the length of the
|
||||||
|
path it was showing — a 75-character path alone demanded 1501 px. The path now
|
||||||
|
clips when the window is genuinely narrow instead of widening the window, and
|
||||||
|
the content minimum is 972 px.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
|
- **The divider between the job list and the details pane is draggable.**
|
||||||
|
Previously the list was pinned at its natural width and the details pane took
|
||||||
|
whatever was left, so a long command or a deep folder path could not be given
|
||||||
|
more room. Either pane can now be widened at the other's expense, and neither
|
||||||
|
can be dragged below its own content, so the details pane condenses rather
|
||||||
|
than clipping. The divider opens at the list's natural width; its position is
|
||||||
|
not saved, so a restart reopens at that default.
|
||||||
|
|
||||||
|
**History:**
|
||||||
|
|
||||||
|
- **Columns measure their own content.** Time, Trigger and State were fixed
|
||||||
|
pixel widths with as little as 1.6 px of headroom and truncated their own
|
||||||
|
values on a scaled UI or at a larger text size; all five now size themselves
|
||||||
|
from the text they have to show, under the current theme. Job and Detail stay
|
||||||
|
bounded so one long row cannot take over the table.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
|
||||||
|
its layout always intended, rather than 8.
|
||||||
|
- The caption column is as wide as the widest caption instead of a fixed width,
|
||||||
|
which gives each value column about 22 px more and keeps the captions readable
|
||||||
|
at a larger text size.
|
||||||
|
- The **Application** and **About** blocks are about 2 px tighter: every stacked
|
||||||
|
row group in the app now shares one spacing derived from the theme rather than
|
||||||
|
three separately tuned numbers.
|
||||||
|
- The **Theme** dropdown is no longer flush against the **Notifications**
|
||||||
|
checkbox. That shared row spacing pulls rows together by one text inset, which
|
||||||
|
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
|
||||||
|
now keeps the same gap the checkbox rows have.
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- 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
|
||||||
|
the one file the user is invited to hand-edit the least accurate thing in the
|
||||||
|
document; it is now the real default, with each key explained — including why
|
||||||
|
a zero timeout is written out and an unset one is not. The feature list has
|
||||||
|
caught up with the run timeout, the theme, the compact job list, and the
|
||||||
|
per-job overlap and timeout overrides the job dialog has always offered.
|
||||||
|
- **`docs/DEVELOPMENT.md`** is ordered as stack, external libraries, run from
|
||||||
|
source, build, release, CI, behind a two-level table of contents, instead of
|
||||||
|
opening with MSYS2 setup and burying "Run From Source" mid-document. The
|
||||||
|
library table gains versions and licenses, the `package-*` scripts are
|
||||||
|
documented for the first time and labelled by OS, and the Codeberg
|
||||||
|
`RELEASE_TOKEN` note now states the failure mode rather than leaving it to be
|
||||||
|
inferred from a red job: build and packaging succeed, the upload step fails on
|
||||||
|
authentication and takes the job with it, leaving a published release with no
|
||||||
|
assets. The Project Layout section is gone — it duplicated ARCHITECTURE's
|
||||||
|
package map and had drifted out of date.
|
||||||
|
- **Cutting a GitHub release now documents the push mirror it has to survive.**
|
||||||
|
GitHub is a pruning push mirror of Gitea, so `gh release create` creating the
|
||||||
|
tag itself produces a tag Gitea does not know about, which the next
|
||||||
|
synchronisation deletes — orphaning the release and taking its uploaded
|
||||||
|
archives with it, without a single failed step to point at. The procedure is
|
||||||
|
push the tag to Gitea, wait for the mirror, verify the tag on GitHub, then
|
||||||
|
publish with `--verify-tag`.
|
||||||
|
- **`docs/TESTS.md`** matches the suite it indexes again. It listed 130 tests
|
||||||
|
against 170 in the tree, omitted four test files entirely, and named two tests
|
||||||
|
that no longer exist. Every test function now appears exactly once, under the
|
||||||
|
file it actually lives in.
|
||||||
|
- **`docs/ARCHITECTURE.md`** no longer draws the UI calling the autostart
|
||||||
|
manager directly — it does not, and `src/ui` holds no reference to that
|
||||||
|
package — and `platform/desktop` is described by what it does (the XDG desktop
|
||||||
|
entry and icon) rather than as a display-scale helper.
|
||||||
|
- The **~250-line file guideline** is stated as the target it is, with the six
|
||||||
|
files currently over it recorded as a `docs/ROADMAP.md` item. They are to be
|
||||||
|
split in one pass during the next whole-project review, since six separate
|
||||||
|
passes would settle the same seam question six ways.
|
||||||
|
|
||||||
|
## 0.15.0 - 2026-07-26
|
||||||
|
|
||||||
|
**Settings points at the jobs file itself, not the folder holding it.**
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- The **Jobs directory** row is now a **Jobs file** row. Browse opens a file
|
||||||
|
picker filtered to `.json` instead of a folder picker, so the job list can
|
||||||
|
live under any file name — `team-jobs.json`, one file per machine, a file
|
||||||
|
shared over a network drive — rather than a fixed `jobs.json` per folder. The
|
||||||
|
field stays editable, which is how a file that does not exist yet is named.
|
||||||
|
- **Selecting an existing jobs file now loads it.** Previously the current job
|
||||||
|
list was written over whatever was at the new path, which made it impossible
|
||||||
|
to switch to an existing jobs file — its contents were destroyed on Save. Now
|
||||||
|
an existing file wins: its jobs are loaded, normalized, and replace the loaded
|
||||||
|
list, with runtimes, parsed schedules, next-run times, and log-seeded
|
||||||
|
statistics rebuilt around them. A path with no file behind it still receives
|
||||||
|
the current jobs (and its folder is created), which is how the jobs file is
|
||||||
|
renamed or relocated. History records `Jobs loaded — N jobs from <path>`,
|
||||||
|
since the switch happens without a prompt.
|
||||||
|
- Switching to a different jobs file is refused while a job is running: adoption
|
||||||
|
discards every runtime, and a run finishing afterwards would write its result
|
||||||
|
onto whichever job inherited its ID. Settings unrelated to the jobs file still
|
||||||
|
save normally during a run.
|
||||||
|
- Saving a path with no file name (a trailing separator, `.`, `..`) is rejected
|
||||||
|
with "jobs file must include a file name" instead of failing later with an
|
||||||
|
opaque OS error.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
- `Config.JobsDir` / `jobs_dir` is replaced by `Config.JobsFile` / `jobs_file`,
|
||||||
|
which holds the full path including the file name; the default is
|
||||||
|
`"jobs.json"`, resolved against the program folder as before. `Paths.JobsDir`
|
||||||
|
is now derived from the configured file so job saves still create the folder.
|
||||||
|
- A `gosentry.json` written by an earlier version is migrated on load: its
|
||||||
|
`jobs_dir` is joined with `jobs.json`, which is the exact file that version
|
||||||
|
used, and the retired key is dropped when the config is rewritten.
|
||||||
|
- New `app.JobsLoaded{Path, Count}` event, emitted when a selected jobs file
|
||||||
|
replaces the job list; the UI turns it into the History entry. New
|
||||||
|
`storage.LoadJobsFile`, which reads and normalizes a jobs file and reports a
|
||||||
|
missing one as "not found" instead of seeding it the way startup does.
|
||||||
|
|
||||||
|
## 0.14.0 - 2026-07-26
|
||||||
|
|
||||||
|
**Compact job list view, "no timeout" at both timeout levels, and an Open
|
||||||
|
button for the logs folder.**
|
||||||
|
|
||||||
|
**Compact job list view.**
|
||||||
|
|
||||||
|
- The Jobs sidebar can now render each job as a single line — name on the left,
|
||||||
|
status on the right — instead of the three-line block. A toggle button beside
|
||||||
|
the Folder filter switches between **Compact** and **Detailed**; it is
|
||||||
|
labelled with the action it performs, like the "Disable auto" button. Compact
|
||||||
|
fits many more jobs on screen without scrolling; selection, the details panel,
|
||||||
|
the folder filter, and live status updates all work unchanged in both modes.
|
||||||
|
- The choice is persisted as a new `Config.JobListView` field
|
||||||
|
(`"detailed"` / `"compact"`, written to `gosentry.json` as `job_list_view`),
|
||||||
|
so it survives a restart. Empty/legacy configs and any unrecognised value
|
||||||
|
normalize to detailed, so existing installs keep the current look.
|
||||||
|
|
||||||
|
**Jobs sidebar:**
|
||||||
|
|
||||||
|
- 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
|
||||||
|
starts a full label higher.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- The **Logs directory** row gained an **Open** button that shows the folder in
|
||||||
|
the desktop file manager (Explorer on Windows, the XDG handler on Linux), so
|
||||||
|
reading a log file no longer means copying the path by hand. It opens the
|
||||||
|
path currently in the field — including an edit that has not been saved yet —
|
||||||
|
resolving a relative directory against the application folder exactly as the
|
||||||
|
store does. A folder that is missing (the logs directory is created on the
|
||||||
|
first run) or cannot be opened is reported in a dialog.
|
||||||
|
- The Save/Cancel/Defaults row sat flush against the separator above it and the
|
||||||
|
tab's left edge; it now uses the same padding as the other vertical gaps in
|
||||||
|
the tab.
|
||||||
|
|
||||||
|
**Job dialog:**
|
||||||
|
|
||||||
|
- The **Arguments** placeholder now states the field's rule — one argument per
|
||||||
|
line, no quoting — instead of showing a lone example path that left the
|
||||||
|
line-per-argument convention to guesswork.
|
||||||
|
|
||||||
|
**Timeouts: 0 now means "no timeout" at both levels.**
|
||||||
|
|
||||||
|
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs
|
||||||
|
run to completion with no deadline instead of being killed after 30s.
|
||||||
|
- A per-job timeout of `0` now also means "no timeout" and no longer inherits
|
||||||
|
the global default. Leaving the job's timeout **empty** is what inherits.
|
||||||
|
`Job.TimeoutSeconds` became `*int` so the three states — unset, explicit 0,
|
||||||
|
and a positive limit — stay distinguishable in `jobs.json`.
|
||||||
|
- Fixed: a global default of `0` did not survive a restart. `gosentry.json` was
|
||||||
|
loaded with `0` treated as a missing value and silently reset to 30s, so the
|
||||||
|
setting only held for the current session. `default_timeout_seconds` is now
|
||||||
|
written unconditionally and read back as-is.
|
||||||
|
|
||||||
|
Existing jobs and configs are unaffected: a job with no `timeout_seconds` still
|
||||||
|
inherits, and a saved global default of 30 stays 30.
|
||||||
|
|
||||||
|
**Internal:**
|
||||||
|
|
||||||
|
- Job names in the list are truncated through the widget's `Truncation` field;
|
||||||
|
`fyne.TextTruncate` is deprecated in Fyne 2.7.4. Behavior is unchanged.
|
||||||
|
- Docker release builds mount `.gocache/` from the host, so `--rm` container
|
||||||
|
removal no longer wipes `GOCACHE` between runs.
|
||||||
|
- Added `docs/REVIEW.md` (the project-review agenda) and a "Config file
|
||||||
|
compatibility" section in `docs/STANDARDS.md` recording the rule the `Theme`,
|
||||||
|
`JobListView`, and `TimeoutSeconds` fields already follow. Added `CLAUDE.md`.
|
||||||
|
|
||||||
|
## 0.13.0 - 2026-07-26
|
||||||
|
|
||||||
|
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
|
||||||
|
|
||||||
|
**Theme:**
|
||||||
|
- Added a custom Fyne theme derived from the logo and app icon (deep teal
|
||||||
|
primary, amber accent, branded job-status colors) with light and dark
|
||||||
|
variants; users can switch between it and Fyne's default theme from
|
||||||
|
Settings. The choice is persisted as a new `Config.Theme` field
|
||||||
|
(`"default"` / `"gosentry"`), applied at startup before the first frame
|
||||||
|
and live-previewed when picked in Settings. Empty/legacy configs
|
||||||
|
normalize to the default theme so existing installs keep the original
|
||||||
|
look.
|
||||||
|
- The light variant is boldly branded: a soft teal window canvas with
|
||||||
|
white inputs, menus, dialogs, and buttons on top, plus teal-tinted
|
||||||
|
separators, input borders, and table headers, so cards and fields lift
|
||||||
|
off the background instead of reading as a plain accent swap on gray.
|
||||||
|
The dark variant uses deep-teal surfaces to echo the app icon. Text
|
||||||
|
stays dark/light per the base foreground for contrast in both variants.
|
||||||
|
|
||||||
|
**Settings tab:**
|
||||||
|
- Added Cancel and Defaults buttons. Cancel discards unsaved edits by
|
||||||
|
reloading the saved config; Defaults loads built-in default values into
|
||||||
|
the form for review before saving. `domain.DefaultConfig()` is now the
|
||||||
|
single source of truth for default values, shared by storage and the
|
||||||
|
Settings UI.
|
||||||
|
|
||||||
|
## 0.12.0 - 2026-07-25
|
||||||
|
|
||||||
|
**Per-job command timeout:**
|
||||||
|
- Each job may now set its own run timeout (seconds) in the job dialog; leaving
|
||||||
|
it empty inherits a new **Default timeout** in Settings (default 30s), the same
|
||||||
|
inherit pattern as the overlap policy. The details panel shows the effective
|
||||||
|
value, marking inherited jobs as `(global default)`.
|
||||||
|
- The formerly hard-coded 30s guard in `runner.RunJob` is now the configurable
|
||||||
|
default. `StartOnly` fire-and-forget jobs remain unaffected by the run timeout,
|
||||||
|
continuing to measure launch latency only.
|
||||||
|
|
||||||
|
## 0.11.5 - 2026-07-01
|
||||||
|
|
||||||
|
**Quality and documentation polish:**
|
||||||
|
|
||||||
|
- Replaced the interim `docs/FUTURE_WORK.md` with `docs/STANDARDS.md` — a slim,
|
||||||
|
permanent reference for code-quality rules and intentional behavior.
|
||||||
|
- `newMainView` now accepts an injected `*app.Service` for testability;
|
||||||
|
`RunNow` errors are shown in a dialog instead of failing silently.
|
||||||
|
- Empty job lists no longer panic when building the Jobs tab.
|
||||||
|
- Added regression and helper tests for overlap/pause scheduling, UI history
|
||||||
|
helpers, main-view smoke build, and Linux desktop integration.
|
||||||
|
|
||||||
## 0.11.4 - 2026-06-30
|
## 0.11.4 - 2026-06-30
|
||||||
|
|
||||||
**Statistics:**
|
**Statistics:**
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# GoSentry — Code Review (2026-06-29)
|
|
||||||
|
|
||||||
Версия на момент ревью: **0.11.2** (исправления вошли в **0.11.3**)
|
|
||||||
|
|
||||||
## Итог
|
|
||||||
|
|
||||||
| Критерий | Оценка |
|
|
||||||
|----------|--------|
|
|
||||||
| Архитектура | 9/10 |
|
|
||||||
| Сложность vs масштаб | 8/10 |
|
|
||||||
| Качество кода | 8/10 |
|
|
||||||
| Поддерживаемость | 8/10 |
|
|
||||||
| Логические ошибки | 9/10 (после исправлений) |
|
|
||||||
|
|
||||||
Проект зрелый и поддерживаемый для десктопного планировщика (~59 `.go`-файлов). Архитектура слоистая, core-логика хорошо протестирована.
|
|
||||||
|
|
||||||
## Сильные стороны
|
|
||||||
|
|
||||||
- Single-writer `app.Service` с явным locking contract
|
|
||||||
- Разделение `domain.Job` (durable) и `domain.JobRuntime` (transient)
|
|
||||||
- Event-driven UI без обратных вызовов в Fyne под lock
|
|
||||||
- Portable storage от `os.Executable()`
|
|
||||||
- Инъекция `runJob` и `scheduler.Clock` в тестах
|
|
||||||
- Подробная документация (`ARCHITECTURE.md`, inline comments)
|
|
||||||
|
|
||||||
## Найденные проблемы и статус исправлений
|
|
||||||
|
|
||||||
| # | Проблема | Серьёзность | Статус |
|
|
||||||
|---|----------|-------------|--------|
|
|
||||||
| 1 | Data race: `store.Paths` в `executeRun` без lock | Высокая | Исправлено |
|
|
||||||
| 2 | Run стартует при ошибке `SaveJobs` | Средняя | Исправлено |
|
|
||||||
| 3 | CRUD эмитит events при failed save | Средняя | Исправлено |
|
|
||||||
| 4 | Overlap queue — только один `Pending` | Средняя | Исправлено (`PendingRuns`) |
|
|
||||||
| 5 | `time.Now()` vs scheduler clock в `startRunLocked` | Низкая | Исправлено |
|
|
||||||
| 6 | Silent log write failures | Низкая | Исправлено |
|
|
||||||
| 7 | Невалидный per-job `overlap_policy` | Низкая | Исправлено |
|
|
||||||
| 8 | Docs drift (YAML, RunNow/pause) | Низкая | Исправлено |
|
|
||||||
| 9 | `StartOnly` игнорировал cancel context | Низкая | Исправлено |
|
|
||||||
| 10 | `SeedStats` коллизия sanitized имён | Низкая | Исправлено (match по `job_id`) |
|
|
||||||
| 11 | `AvgDurationMS` seed vs live расходились | Низкая | Исправлено (`TimedRunCount`) |
|
|
||||||
| 12 | Legacy ticket-ссылки в комментариях | Низкая | Исправлено |
|
|
||||||
|
|
||||||
## Намеренное поведение (не баги)
|
|
||||||
|
|
||||||
- `RunNow` разрешён при global pause и для disabled jobs
|
|
||||||
- Sequential mode — FIFO по порядку в `jobs.json`
|
|
||||||
- Scheduler tick 1s — sub-second `@every` не поддерживается
|
|
||||||
- Command timeout 30s — глобальный лимит
|
|
||||||
|
|
||||||
## Рекомендации на будущее
|
|
||||||
|
|
||||||
- UI widget tests или smoke E2E
|
|
||||||
- Per-job command timeout в конфиге
|
|
||||||
@@ -1,18 +1,51 @@
|
|||||||
# GoSentry — Development
|
# GoSentry — Development
|
||||||
|
|
||||||
Build instructions, project layout, and dependency information for contributors.
|
Toolchain, dependency, build, and release information for contributors.
|
||||||
|
|
||||||
## Requirements
|
## Contents
|
||||||
|
|
||||||
Common:
|
1. [Technology Stack and Tools](#1-technology-stack-and-tools)
|
||||||
|
- [Toolchain — Windows](#toolchain--windows)
|
||||||
|
- [Toolchain — Linux](#toolchain--linux)
|
||||||
|
- [Repository scripts](#repository-scripts)
|
||||||
|
2. [External Libraries](#2-external-libraries)
|
||||||
|
3. [Run From Source](#3-run-from-source)
|
||||||
|
4. [Building the Executable](#4-building-the-executable)
|
||||||
|
- [Windows](#windows)
|
||||||
|
- [Linux](#linux)
|
||||||
|
- [Linux using Docker](#linux-using-docker)
|
||||||
|
5. [Building a Release](#5-building-a-release)
|
||||||
|
- [All targets from Linux](#all-targets-from-linux)
|
||||||
|
- [Packaging](#packaging)
|
||||||
|
6. [CI](#6-ci)
|
||||||
|
- [Cutting a release](#cutting-a-release)
|
||||||
|
- [Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)
|
||||||
|
|
||||||
|
## 1. Technology Stack and Tools
|
||||||
|
|
||||||
|
GoSentry is a single desktop process written in Go with a Fyne GUI. There is no
|
||||||
|
server component and no external runtime: the release artifact is one native
|
||||||
|
executable per platform.
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
| --- | --- |
|
||||||
|
| Language | Go 1.22 or newer |
|
||||||
|
| GUI toolkit | Fyne v2 (OpenGL desktop backend) |
|
||||||
|
| Scheduling | `robfig/cron/v3` expression parser |
|
||||||
|
| Persistence | Plain JSON files (`gosentry.json`, `jobs.json`) |
|
||||||
|
| Build | `go build` driven by the scripts in `scripts/` |
|
||||||
|
| Reproducible builds | Docker (`golang:1.22-bookworm` based [Dockerfile](../Dockerfile)) |
|
||||||
|
| CI | GitHub Actions and Forgejo Actions (Codeberg) |
|
||||||
|
|
||||||
|
CGO is mandatory. The Fyne desktop backend links against native OpenGL and
|
||||||
|
window-system libraries, so a C compiler must be present for every build,
|
||||||
|
including `go run` and `go test`.
|
||||||
|
|
||||||
|
### Toolchain — Windows
|
||||||
|
|
||||||
- [Go](https://go.dev/) 1.22 or newer.
|
- [Go](https://go.dev/) 1.22 or newer.
|
||||||
|
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin` (plus `windres` for the icon
|
||||||
Windows:
|
resource).
|
||||||
|
|
||||||
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
|
|
||||||
|
|
||||||
Install these dependencies on Windows:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
|
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
|
||||||
@@ -33,12 +66,12 @@ Test-Path C:\msys64\ucrt64\bin\gcc.exe
|
|||||||
Test-Path C:\msys64\ucrt64\bin\windres.exe
|
Test-Path C:\msys64\ucrt64\bin\windres.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux:
|
### Toolchain — Linux
|
||||||
|
|
||||||
|
- [Go](https://go.dev/) 1.22 or newer.
|
||||||
- A C compiler.
|
- A C compiler.
|
||||||
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11 development packages.
|
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11
|
||||||
|
development packages.
|
||||||
On Debian/Ubuntu, the Linux dependencies are typically:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
|
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
|
||||||
@@ -46,84 +79,41 @@ On Debian/Ubuntu, the Linux dependencies are typically:
|
|||||||
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
### Repository scripts
|
||||||
|
|
||||||
### Windows
|
| Script | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `scripts/test.bat`, `scripts/test.sh` | `go vet ./...` then `go test -race ./...` |
|
||||||
|
| `scripts/build-windows.bat` | Windows amd64 executable |
|
||||||
|
| `scripts/build-linux.sh` | Linux amd64 executable |
|
||||||
|
| `scripts/build-linux-docker.sh` | Linux amd64 executable, built in Docker |
|
||||||
|
| `scripts/build-release-linux.sh` | Multi-target release artifacts from one Linux/Docker workflow |
|
||||||
|
| `scripts/package-windows.bat`, `scripts/package-linux.sh` | Wrap a built binary into a distributable archive |
|
||||||
|
| `scripts/ci-build-release.sh` | Entry point used by both CI workflows |
|
||||||
|
|
||||||
```powershell
|
Build outputs are written to `dist/`. The package layout is documented in
|
||||||
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
|
[ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
# to the repository root first, so double-clicking it from Explorer works. It
|
|
||||||
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
|
|
||||||
# when windres is available, and uses the Windows GUI subsystem so no console
|
|
||||||
# window opens at startup.
|
|
||||||
.\scripts\build-windows.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
The Windows build is created as a GUI application, so it does not open a terminal window.
|
## 2. External Libraries
|
||||||
|
|
||||||
The binary is written to:
|
GoSentry keeps the direct dependency list intentionally small. GoSentry itself
|
||||||
|
is distributed under the [MIT License](../LICENSE).
|
||||||
|
|
||||||
```text
|
| Dependency | Version | Repository | License |
|
||||||
dist\windows\gosentry-0.9.0-windows-amd64.exe
|
| --- | --- | --- | --- |
|
||||||
```
|
| Go toolchain | 1.22+ | https://go.googlesource.com/go | BSD 3-Clause |
|
||||||
|
| `fyne.io/fyne/v2` | v2.7.4 | https://github.com/fyne-io/fyne | BSD 3-Clause |
|
||||||
|
| `github.com/robfig/cron/v3` | v3.0.1 | https://github.com/robfig/cron | MIT |
|
||||||
|
|
||||||
### Linux
|
The remaining entries in `go.mod` are indirect dependencies pulled in by Fyne
|
||||||
|
and the Go module resolver. To list every direct and indirect module used by the
|
||||||
|
current checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
go list -m all
|
||||||
chmod +x ./scripts/build-linux.sh
|
|
||||||
./scripts/build-linux.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary is written to:
|
## 3. Run From Source
|
||||||
|
|
||||||
```text
|
|
||||||
dist/linux/gosentry-0.9.0-linux-amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux using Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Builds the Linux binary inside Docker using the versioned image tag
|
|
||||||
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
|
|
||||||
# where the native Linux/Fyne packages are not installed locally.
|
|
||||||
chmod +x ./scripts/build-linux-docker.sh
|
|
||||||
./scripts/build-linux-docker.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary is copied to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dist/linux/gosentry-0.9.0-linux-amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release build from Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
|
||||||
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
|
||||||
# environment; the build commands live in this script. Docker runs the build
|
|
||||||
# with the current user's UID/GID so dist/ files are not owned by root.
|
|
||||||
chmod +x ./scripts/build-release-linux.sh
|
|
||||||
./scripts/build-release-linux.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Non-interactive release builds can pass target names:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build only Linux arm64 and Windows amd64 artifacts.
|
|
||||||
./scripts/build-release-linux.sh linux-arm64 windows-amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
The binaries are copied to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dist/linux/gosentry-0.9.0-linux-amd64
|
|
||||||
dist/linux/gosentry-0.9.0-linux-arm64
|
|
||||||
dist/windows/gosentry-0.9.0-windows-amd64.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run From Source
|
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
@@ -146,41 +136,217 @@ Linux:
|
|||||||
CGO_ENABLED=1 go run ./cmd/gosentry
|
CGO_ENABLED=1 go run ./cmd/gosentry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Layout
|
The same environment is required for the test suite — see
|
||||||
|
[TESTS.md](TESTS.md):
|
||||||
|
|
||||||
- `cmd/gosentry` — entry point; starts the desktop app.
|
```powershell
|
||||||
- `src/domain` — pure value types: `Job`, `Config`, `RunRecord`, `Schedule`, `JobRuntime`.
|
scripts\test.bat
|
||||||
- `src/app` — `Service`: sole owner of job and runtime state; emits typed events to the UI.
|
```
|
||||||
- `src/scheduler` — pure timing loop; calls `Service.RunDue` on every tick.
|
|
||||||
- `src/runner` — shell command execution, log file writing, and log cleanup.
|
|
||||||
- `src/storage` — JSON persistence (`gosentry.json`, `jobs.json`).
|
|
||||||
- `src/platform/autostart` — `Manager` interface with Windows (shortcut) and Linux (XDG) implementations.
|
|
||||||
- `src/platform/desktop` — display-scale helper (Linux only).
|
|
||||||
- `src/platform/winproc` — hidden-window startup flags (Windows only).
|
|
||||||
- `src/ui` — Fyne windows, tabs, and dialogs; reads service state through events.
|
|
||||||
- `assets` — app icons embedded into the application binary.
|
|
||||||
- `scripts` — build helpers.
|
|
||||||
- `docs` — architecture notes, changelog, and roadmap.
|
|
||||||
|
|
||||||
Build outputs are written to `dist/`.
|
## 4. Building the Executable
|
||||||
|
|
||||||
## Dependencies
|
### Windows
|
||||||
|
|
||||||
GoSentry keeps the direct dependency list intentionally small:
|
```powershell
|
||||||
|
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
|
||||||
|
# to the repository root first, so double-clicking it from Explorer works. It
|
||||||
|
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
|
||||||
|
# when windres is available, and uses the Windows GUI subsystem so no console
|
||||||
|
# window opens at startup.
|
||||||
|
.\scripts\build-windows.bat
|
||||||
|
```
|
||||||
|
|
||||||
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
|
The Windows build is created as a GUI application, so it does not open a
|
||||||
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
terminal window. The binary is written to:
|
||||||
|
|
||||||
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
|
```text
|
||||||
|
dist\windows\gosentry-<version>-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
Source repositories for mirroring:
|
### Linux
|
||||||
|
|
||||||
- Go toolchain: https://go.googlesource.com/go
|
|
||||||
- Fyne: https://github.com/fyne-io/fyne
|
|
||||||
- robfig/cron: https://github.com/robfig/cron
|
|
||||||
|
|
||||||
To list every direct and indirect Go module used by the current checkout:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go list -m all
|
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
||||||
|
chmod +x ./scripts/build-linux.sh
|
||||||
|
./scripts/build-linux.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The binary is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/gosentry-<version>-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux using Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Builds the Linux binary inside Docker using the versioned image tag
|
||||||
|
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
|
||||||
|
# where the native Linux/Fyne packages are not installed locally.
|
||||||
|
chmod +x ./scripts/build-linux-docker.sh
|
||||||
|
./scripts/build-linux-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/gosentry-<version>-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Building a Release
|
||||||
|
|
||||||
|
### All targets from Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
||||||
|
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
||||||
|
# environment; the build commands live in this script. Docker runs the build
|
||||||
|
# with the current user's UID/GID so dist/ files are not owned by root.
|
||||||
|
chmod +x ./scripts/build-release-linux.sh
|
||||||
|
./scripts/build-release-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-interactive release builds can pass target names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build only Linux arm64 and Windows amd64 artifacts.
|
||||||
|
./scripts/build-release-linux.sh linux-arm64 windows-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
The binaries are copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/gosentry-<version>-linux-amd64
|
||||||
|
dist/linux/gosentry-<version>-linux-arm64
|
||||||
|
dist/windows/gosentry-<version>-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
The `package-*` scripts build the binary for their platform and wrap it in a
|
||||||
|
distributable archive together with `README.md` and `CHANGELOG.md`:
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\package-windows.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist\windows\gosentry-<version>-windows-amd64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/package-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/gosentry-<version>-linux-amd64.tar.gz
|
||||||
|
dist/linux/gosentry-<version>-linux-arm64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
The arm64 archive is produced only when the `aarch64-linux-gnu-gcc` cross
|
||||||
|
compiler is available; otherwise that target is skipped with a message.
|
||||||
|
|
||||||
|
The version stamped into the file names and into the binary comes from
|
||||||
|
`src/app/version.go`.
|
||||||
|
|
||||||
|
## 6. CI
|
||||||
|
|
||||||
|
Tagged releases are built automatically on both GitHub and Codeberg:
|
||||||
|
|
||||||
|
- `.github/workflows/release.yml` — GitHub Actions.
|
||||||
|
- `.forgejo/workflows/release.yml` — Forgejo Actions (Codeberg).
|
||||||
|
|
||||||
|
Both run inside `golang:1.22-bookworm` (the same base image as the
|
||||||
|
[Dockerfile](../Dockerfile)), install the cross toolchain, and call
|
||||||
|
`scripts/ci-build-release.sh`, which builds and packages all three artifacts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/gosentry-<version>-linux-amd64.tar.gz
|
||||||
|
dist/linux/gosentry-<version>-linux-arm64.tar.gz
|
||||||
|
dist/windows/gosentry-<version>-windows-amd64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows binary is cross-compiled with MinGW-w64 from the Linux job, so no
|
||||||
|
Windows runner is required. Each archive contains the executable plus `README.md`
|
||||||
|
and `CHANGELOG.md`, matching the local `package-*` scripts.
|
||||||
|
|
||||||
|
### Cutting a release
|
||||||
|
|
||||||
|
Before tagging:
|
||||||
|
|
||||||
|
1. Bump `src/app/version.go`. The tag must match it exactly.
|
||||||
|
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
||||||
|
3. Retake the README screenshots (`docs/screenshots/screenshot_jobs.PNG`,
|
||||||
|
`docs/screenshots/screenshot_settings.PNG`,
|
||||||
|
`docs/screenshots/screenshot_history.PNG`) if the GUI changed its appearance. This is
|
||||||
|
easy to forget because nothing fails without it: `README.md` is packaged
|
||||||
|
inside every release archive and is what the forge shows on the project page,
|
||||||
|
so a stale shot advertises an application that no longer exists. Take them
|
||||||
|
from a real build, not from a development run with test data.
|
||||||
|
4. Run `scripts/test.bat` (or `go vet ./... && go test -race ./...`) and push
|
||||||
|
`main`, so the tag lands on a commit the forge actually has.
|
||||||
|
|
||||||
|
Then create and publish a release with a matching `v` tag on the forge (GitHub
|
||||||
|
Releases / Codeberg releases). `origin` is the Gitea repository, and GitHub is a
|
||||||
|
push mirror of it, so the tag is pushed to Gitea and reaches GitHub through the
|
||||||
|
mirror — never created on GitHub directly (see
|
||||||
|
[Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v0.11.5
|
||||||
|
git push origin v0.11.5 # Gitea; and to the Codeberg remote
|
||||||
|
|
||||||
|
# wait for the mirror, then confirm GitHub actually has the tag
|
||||||
|
git ls-remote --tags https://github.com/mixeme/gosentry.git v0.11.5
|
||||||
|
|
||||||
|
gh release create v0.11.5 --verify-tag --generate-notes # GitHub; publishes the release
|
||||||
|
```
|
||||||
|
|
||||||
|
Publishing the release triggers the workflow: it strips the leading `v` from
|
||||||
|
the tag and injects it as the version (so the tag must match `version.go`),
|
||||||
|
builds the archives, and attaches them to that release. `workflow_dispatch`
|
||||||
|
also allows a manual, upload-free build to smoke-test the pipeline.
|
||||||
|
|
||||||
|
Codeberg publishing needs a repository secret named `RELEASE_TOKEN` (a Codeberg
|
||||||
|
access token with the `write:repository` scope) under
|
||||||
|
**Settings → Actions → Secrets**. Without it the build and packaging steps still
|
||||||
|
succeed, but the upload step fails on authentication and takes the job down with
|
||||||
|
it, leaving a published release with no attached assets. GitHub needs no such
|
||||||
|
setup: `softprops/action-gh-release` falls back to the built-in `GITHUB_TOKEN`,
|
||||||
|
and the workflow already grants it `contents: write`.
|
||||||
|
|
||||||
|
### Releasing through the GitHub push mirror
|
||||||
|
|
||||||
|
The GitHub repository `mixeme/gosentry` is not a separate remote you push to; it
|
||||||
|
is a push mirror driven by Gitea. Gitea mirrors with pruning, so every ref that
|
||||||
|
exists on GitHub but not in Gitea is deleted on the next synchronisation.
|
||||||
|
|
||||||
|
This is what breaks the obvious way of cutting a GitHub release. `gh release
|
||||||
|
create v1.0.0` creates the tag on GitHub when it is missing — a tag Gitea has
|
||||||
|
never heard of. The next mirror run prunes it, GitHub orphans the release whose
|
||||||
|
tag disappeared and turns it into a draft, and the release looks deleted on the
|
||||||
|
Releases page. The archives go with it. Nothing reports an error: the workflow
|
||||||
|
ran, the assets uploaded, and the release evaporated afterwards.
|
||||||
|
|
||||||
|
The order that works is therefore:
|
||||||
|
|
||||||
|
1. `git push origin <tag>` — the tag enters Gitea, which owns it.
|
||||||
|
2. Wait for the mirror, or force it with **Settings → Repository → Mirror
|
||||||
|
Settings → Synchronize Now** in Gitea.
|
||||||
|
3. `git ls-remote --tags https://github.com/mixeme/gosentry.git <tag>` — confirm
|
||||||
|
GitHub has it.
|
||||||
|
4. `gh release create <tag> --verify-tag …` — `--verify-tag` is the guard, not a
|
||||||
|
nicety: without it `gh` silently creates the doomed tag when the mirror has
|
||||||
|
not caught up yet.
|
||||||
|
|
||||||
|
Release notes and assets are GitHub-side metadata; a mirror push cannot touch
|
||||||
|
them, so once the release sits on a mirrored tag, later synchronisations leave
|
||||||
|
it alone. Two consequences follow. Moving a published tag in Gitea force-pushes
|
||||||
|
it on GitHub and leaves the release pointing at a different commit, and deleting
|
||||||
|
a published tag in Gitea destroys the GitHub release along with its uploaded
|
||||||
|
archives — neither is recoverable from the mirror side. Codeberg is unaffected:
|
||||||
|
its releases live in the same forge as its tags.
|
||||||
|
|||||||
@@ -3,18 +3,177 @@
|
|||||||
This file tracks planned GoSentry work that is larger than a single bug fix.
|
This file tracks planned GoSentry work that is larger than a single bug fix.
|
||||||
Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
||||||
|
|
||||||
## Open Items
|
## Features
|
||||||
|
|
||||||
|
User-facing functionality that is not blocked on a framework or platform gap.
|
||||||
|
|
||||||
|
### Update check from GitHub releases
|
||||||
|
|
||||||
|
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
|
||||||
|
`.github/workflows/release.yml`), but the app never tells the user a newer
|
||||||
|
version exists — they have to check the releases page by hand.
|
||||||
|
|
||||||
|
Add an update check that queries the GitHub Releases API
|
||||||
|
(`GET /repos/mixeme/gosentry/releases/latest`) for the latest published tag,
|
||||||
|
strips the leading `v`, and compares it against `app.Version`. When a newer
|
||||||
|
version is available, surface it non-intrusively — an "Update available"
|
||||||
|
line in Settings (next to the existing version/build info) with a hyperlink
|
||||||
|
to the release page, not a modal on launch.
|
||||||
|
|
||||||
|
Design notes / open questions:
|
||||||
|
|
||||||
|
- *Opt-in and offline-safe.* The check makes a network request, so it must be
|
||||||
|
off by default (or clearly consented) and never block startup. Failures
|
||||||
|
(offline, rate-limited, API change) should be silent — no error dialogs for a
|
||||||
|
best-effort convenience feature.
|
||||||
|
- *Version comparison.* Compare semantic versions, not strings, so `0.12.0`
|
||||||
|
reads as newer than `0.9.0`. A tiny semver comparator in `app` (or a small
|
||||||
|
dependency) avoids lexical bugs.
|
||||||
|
- *Where the check lives.* Keep it in the `app` layer behind the Service so the
|
||||||
|
UI only renders the result, and cache the last check so opening Settings
|
||||||
|
repeatedly does not spam the API (unauthenticated GitHub allows 60 req/h).
|
||||||
|
- *Repo coordinates.* The primary remote is Gitea; the GitHub repo used for
|
||||||
|
releases is [`mixeme/gosentry`](https://github.com/mixeme/gosentry) and must
|
||||||
|
be wired in explicitly (constant or build-time value) rather than derived from
|
||||||
|
`origin`.
|
||||||
|
- *No auto-download.* Scope is detection and notification only; installing the
|
||||||
|
update stays a manual click-through to the release page.
|
||||||
|
|
||||||
|
### Import/export jobs as a cron table
|
||||||
|
|
||||||
|
Jobs can only be moved between machines by copying `jobs.json` by hand. Add
|
||||||
|
"Import" / "Export" actions (Settings tab, file dialogs) that read and write a
|
||||||
|
crontab-style text file, so a job list can be shared, version-controlled, or
|
||||||
|
seeded from an existing Unix crontab.
|
||||||
|
|
||||||
|
Export writes one line per job — schedule fields, then command and arguments —
|
||||||
|
and import parses the same format back into `domain.Job` values.
|
||||||
|
|
||||||
|
Design notes / open questions:
|
||||||
|
|
||||||
|
- *The job model is wider than a crontab line.* `Name`, `Folder`, `StartOnly`,
|
||||||
|
`OverlapPolicy`, `TimeoutSeconds`, and `Enabled` have no cron equivalent.
|
||||||
|
Either accept a lossy export (schedule + command only) or carry the extra
|
||||||
|
fields in a structured comment above each line (`# gosentry: name=… folder=…
|
||||||
|
timeout=…`), which keeps the file readable by real cron while making the
|
||||||
|
round-trip lossless. The comment form is preferred; decide the exact key set
|
||||||
|
before implementing.
|
||||||
|
- *Disabled jobs.* `Enabled: false` maps naturally to a commented-out line, but
|
||||||
|
then a disabled job is indistinguishable from a user's own comment unless the
|
||||||
|
`# gosentry:` marker is present. Pick one representation and document it.
|
||||||
|
- *`@every` is not crontab.* GoSentry accepts `@every 10s` (see
|
||||||
|
[`domain.Parse`](../src/domain/schedule.go)), which no cron implementation
|
||||||
|
understands. Exporting it produces a file that is not a valid crontab;
|
||||||
|
exporting it as an approximation would silently change the schedule. Keep the
|
||||||
|
raw string and flag the file as GoSentry-flavoured, rather than converting.
|
||||||
|
- *Command vs arguments.* Crontab has a single command string; GoSentry splits
|
||||||
|
`Command` and `Arguments`. Import must split the line the same way the runner
|
||||||
|
would (see `runner/invocation*.go`, which differs per OS), and export must
|
||||||
|
join them back without changing quoting.
|
||||||
|
- *What to skip on import.* Environment assignments (`SHELL=`, `PATH=`,
|
||||||
|
`MAILTO=`), six-field (seconds) crontabs, and `@reboot` are outside what
|
||||||
|
`domain.Parse` accepts. Skip them, and report which lines were skipped and
|
||||||
|
why — a partial import that silently drops rows is worse than a failed one.
|
||||||
|
- *Merge semantics.* Import must decide between replacing the job list and
|
||||||
|
appending to it, and must assign fresh IDs rather than trusting the file.
|
||||||
|
Appending with a confirmation dialog is the safer default; replacing needs an
|
||||||
|
explicit "this deletes N jobs" confirmation.
|
||||||
|
- *Where it lives.* Encoding/decoding is pure text handling and belongs in
|
||||||
|
`domain` (or a small `storage` codec) with unit tests over round-trips; the
|
||||||
|
Service exposes import/export operations; the UI only picks the file and
|
||||||
|
shows the outcome.
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
OS-specific improvements outside the Fyne abstraction.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Fyne / UI
|
||||||
|
|
||||||
|
GUI-layer gaps and trade-offs. Each item below states its **blocker**:
|
||||||
|
|
||||||
|
- **Fyne API** — the missing API is the root cause. Qt, GTK, and native
|
||||||
|
toolkits usually expose the same capability; a future Fyne release is the
|
||||||
|
preferred fix. Some items have no viable workaround; others could be bypassed
|
||||||
|
with `src/platform/` code, but that cost is not justified while upstream might
|
||||||
|
still add the API.
|
||||||
|
- **UX** — shippable with today's widgets; deferred until a cleaner approach
|
||||||
|
exists (a nicer Fyne widget would help but is not required).
|
||||||
|
|
||||||
|
### Dynamic tray icon toggle
|
||||||
|
|
||||||
|
**Blocker: Fyne API** (no viable workaround).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### History tab — column filters (Trigger / Job / State)
|
||||||
|
|
||||||
|
**Blocker: UX** (not a Fyne release).
|
||||||
|
|
||||||
|
Add dropdown filters above the History table so the user can narrow rows by
|
||||||
|
trigger source, job name, or run state. A filter bar built from `widget.Select`
|
||||||
|
widgets above the table would work today — filter the row slice in app code —
|
||||||
|
but `widget.Table` has no built-in filter API and the ad-hoc bar feels visually
|
||||||
|
out-of-place. Revisit when Fyne adds first-class column filtering or a
|
||||||
|
composable data-grid widget, or when a hand-rolled bar is acceptable.
|
||||||
|
|
||||||
### Window size persistence *(frozen)*
|
### Window size persistence *(frozen)*
|
||||||
|
|
||||||
|
**Blocker: Fyne API** (costly platform workaround possible).
|
||||||
|
|
||||||
Window size is currently **not** saved on quit or close. Saving was disabled
|
Window size is currently **not** saved on quit or close. Saving was disabled
|
||||||
because `w.Canvas().Size()` returns the maximized dimensions when the window is
|
because `w.Canvas().Size()` returns the maximized dimensions when the window is
|
||||||
maximized, which would corrupt the stored size on the next launch.
|
maximized, which would corrupt the stored size on the next launch.
|
||||||
|
|
||||||
Re-enabling requires a cross-platform way to detect the maximized state before
|
Fyne v2.x has no API to query window state (maximized, normal, etc.) — unlike
|
||||||
saving. Fyne v2.x has no API for this; it needs per-OS native calls:
|
Qt, GTK, or platform-native toolkits. If Fyne added something like
|
||||||
`IsZoomed` (Windows), `_NET_WM_STATE` (X11/Linux), `NSWindow.isZoomed`
|
`Window.IsMaximized()`, persistence could be re-enabled without any
|
||||||
(macOS). Unfreeze once that detection is in place.
|
platform-specific code; that upstream API is the preferred unblock.
|
||||||
|
|
||||||
|
A bypass through per-OS native detection (`IsZoomed` on Windows,
|
||||||
|
`_NET_WM_STATE` on X11, `NSWindow.isZoomed` on macOS) is technically possible,
|
||||||
|
similar to other `src/platform/` work, but Fyne does not expose the underlying
|
||||||
|
window handle, so the bypass is fragile and expensive. The item stays frozen
|
||||||
|
until either Fyne ships window-state API or that platform cost is judged worth
|
||||||
|
paying.
|
||||||
|
|
||||||
**Disadvantages of a platform-specific approach:**
|
**Disadvantages of a platform-specific approach:**
|
||||||
|
|
||||||
@@ -38,10 +197,21 @@ saving. Fyne v2.x has no API for this; it needs per-OS native calls:
|
|||||||
headless test driver; it requires a real display and manual or screen-capture
|
headless test driver; it requires a real display and manual or screen-capture
|
||||||
automation per platform.
|
automation per platform.
|
||||||
|
|
||||||
### History tab — column filters (Trigger / Job / State)
|
## Maintenance
|
||||||
|
|
||||||
Add dropdown filters above the History table so the user can narrow rows by
|
Technical debt and one-time cleanup with no user-visible feature surface.
|
||||||
trigger source, job name, or run state. Blocked on Fyne native support: the
|
|
||||||
current `widget.Table` has no built-in filter API, and a filter bar built from
|
### Retire the config compatibility shims
|
||||||
`widget.Select` widgets above the table feels visually out-of-place. Revisit
|
|
||||||
when Fyne adds first-class column filtering or a composable data-grid widget.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# GoSentry — Standards
|
||||||
|
|
||||||
|
Quality rules and intentional behavior for contributors. Package contracts live
|
||||||
|
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
- Follow package contracts in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
- User-facing errors → `dialog.ShowError` or a History event, never a silent `return`.
|
||||||
|
- Pure helpers → unit test in the same package.
|
||||||
|
- Fixes with severity ≥ medium → regression test.
|
||||||
|
- Documented intentional behavior → section below, not a backlog bug.
|
||||||
|
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
||||||
|
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
|
||||||
|
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
|
||||||
|
log-directory scan, or a pass over every log header inside it makes a UI
|
||||||
|
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
|
||||||
|
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
|
||||||
|
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
|
||||||
|
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
|
||||||
|
writes still reach the file in the order their snapshots were taken; log
|
||||||
|
cleanup and `runner.SeedStats` run from plain snapshots.
|
||||||
|
- A size that must follow the theme is **measured at build time, not written as
|
||||||
|
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
||||||
|
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
||||||
|
the one theme it was tuned against and clips under any other. Measure the real
|
||||||
|
widget, or derive the value from the theme, in a named helper: `rowOverlap`
|
||||||
|
(theme padding), `captionColumnWidth` and `textColumnWidth` (the widest of the
|
||||||
|
actual strings), `activityRowsHeight` (the list's own row template). The same
|
||||||
|
applies to a ratio computed from an absolute width — see `initialSplitOffset`.
|
||||||
|
A raw pixel literal is left only where nothing about it tracks the theme, and
|
||||||
|
says so in a comment.
|
||||||
|
|
||||||
|
## Config file compatibility
|
||||||
|
|
||||||
|
There is no migration step: `gosentry.json` and `jobs.json` are read as-is, are
|
||||||
|
meant to be hand-editable, and may have been written by an older version. A
|
||||||
|
change to their shape has to stay compatible on its own.
|
||||||
|
|
||||||
|
- A new `Config` field is tagged `omitempty`, and its zero value must mean the
|
||||||
|
behavior that existed before the field was added — a file written without it
|
||||||
|
keeps working unchanged. `DefaultConfig()` still sets the value explicitly.
|
||||||
|
- A zero that carries meaning is not a missing field and must not be backfilled
|
||||||
|
on load. See `DefaultTimeoutSeconds` in `storage.loadOrCreateConfig` and
|
||||||
|
`Job.TimeoutSeconds *int`, where unset and `0` are different answers.
|
||||||
|
- An unrecognised enum value reads as the default rather than an error, through
|
||||||
|
one helper that every consumer shares (`JobListView.IsCompact`, `ui.themeFor`),
|
||||||
|
and is normalized before being written back, so the file never gains a value
|
||||||
|
no reader understands.
|
||||||
|
- A renamed key keeps the old field on `Config` (tagged `omitempty`) purely so
|
||||||
|
it can still be read. `storage.loadOrCreateConfig` converts it to the new
|
||||||
|
field and clears it, so the retired key disappears on the next save. See
|
||||||
|
`Config.JobsDir` → `Config.JobsFile`. Where the new field has a non-empty
|
||||||
|
default, clear that default before unmarshalling, or "the file omits it" and
|
||||||
|
"the file sets it" become indistinguishable and the conversion never runs.
|
||||||
|
- Each of the three gets a test: the default in `storage`, the normalization in
|
||||||
|
`domain`, and a round-trip through the real config file in `app`.
|
||||||
|
|
||||||
|
## Intentional behavior (not bugs)
|
||||||
|
|
||||||
|
- `RunNow` is allowed during global pause and for disabled jobs.
|
||||||
|
- Selecting a jobs file that already exists **loads** it: its jobs replace the
|
||||||
|
in-memory list, which is the only way the user can switch between job lists. A
|
||||||
|
path with no file behind it receives the current jobs (rename/relocate). The
|
||||||
|
switch is refused while a job is running, because adoption drops every runtime
|
||||||
|
and a finishing run would then write its result onto whichever job inherited
|
||||||
|
its ID.
|
||||||
|
- Sequential mode runs jobs FIFO by order in `jobs.json`.
|
||||||
|
- Scheduler tick is 1s — sub-second `@every` intervals are not supported.
|
||||||
|
- Command timeout defaults to no timeout globally (`Config.DefaultTimeoutSeconds`
|
||||||
|
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
||||||
|
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
||||||
|
normalized away on load — 0 is a value, not a missing field.
|
||||||
|
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
|
||||||
|
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
|
||||||
|
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
|
||||||
|
negative count is rejected), and `storage.loadOrCreateConfig` no longer
|
||||||
|
backfills 0 to 100 / 30 — a config written before either field existed still
|
||||||
|
picks up the default because `json.Unmarshal` leaves an absent key holding
|
||||||
|
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
|
||||||
|
relies on.
|
||||||
|
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
|
||||||
|
launch something and let go of it, so the runner builds that invocation on
|
||||||
|
`context.Background()`, not on the application's lifecycle context: quitting
|
||||||
|
GoSentry (or cancelling a run) does not stop a process it started this way, and
|
||||||
|
`Service.Stop()` reaches only jobs the runner is still waiting on. The
|
||||||
|
uncancelable context is also what keeps `os/exec` from leaving a watcher
|
||||||
|
goroutine per run — it only starts one when the context can be done, and
|
||||||
|
`StartOnly` never calls `Wait` to end it.
|
||||||
|
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||||
|
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||||
|
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
- **History is capped and its columns only widen.** The tab keeps the newest
|
||||||
|
`maxHistoryRows` records and drops the oldest, the way `maxJobLogs` caps a
|
||||||
|
job's own activity list — an app left in the tray records thousands of runs a
|
||||||
|
day, each carrying the run's full captured output. Column widths are folded in
|
||||||
|
one record at a time instead of rescanned from every row, so a column never
|
||||||
|
narrows when a record ages out: the rows on screen were laid out against the
|
||||||
|
wider value. A theme change is the one case that rescans, because every stored
|
||||||
|
width was measured at the old text size.
|
||||||
|
- Several tests share a coverage profile with another test on purpose, and a few
|
||||||
|
functions sit at 0% on purpose. Both lists live in
|
||||||
|
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
|
||||||
|
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
|
||||||
|
|
||||||
|
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (update check from
|
||||||
|
GitHub releases, cron-table import/export, window size persistence, History
|
||||||
|
column filters).
|
||||||
@@ -22,6 +22,16 @@ Both scripts run:
|
|||||||
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
|
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
|
||||||
2. `go test -race ./...` — tests with race condition detection enabled
|
2. `go test -race ./...` — tests with race condition detection enabled
|
||||||
|
|
||||||
|
The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
|
||||||
|
that means the MSYS2 UCRT64 toolchain described in
|
||||||
|
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||||
|
|
||||||
|
`src/ui` dominates `go test -race ./...`'s wall time — around 229s in the
|
||||||
|
2026-08-05 whole-project review, against under 8s for every other package
|
||||||
|
combined. Budget iteration accordingly: a change confined to `domain`,
|
||||||
|
`storage`, `runner`, `scheduler`, or `app` gets a fast feedback loop; a `ui`
|
||||||
|
change does not.
|
||||||
|
|
||||||
### Manual test commands
|
### Manual test commands
|
||||||
|
|
||||||
Run all tests:
|
Run all tests:
|
||||||
@@ -51,6 +61,37 @@ go test -coverprofile=coverage.out ./src/runner
|
|||||||
go tool cover -html=coverage.out
|
go tool cover -html=coverage.out
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Per-package coverage understates the suite, because several packages are
|
||||||
|
exercised from another one's tests — `domain.NewRuntime`, for instance, is
|
||||||
|
covered by the `app` tests. Measure the engine packages together instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
|
```
|
||||||
|
|
||||||
|
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
||||||
|
splits the comma-separated `-coverpkg` list on its own and the command fails
|
||||||
|
with `directory not found`. Use the stop-parsing token, or quote the whole
|
||||||
|
flag — and note that `--%` swallows the rest of the line, so the profile has to
|
||||||
|
be read by a second command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test --% -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
|
```
|
||||||
|
|
||||||
|
The total is the last line of the profile summary. It is **not** any of the
|
||||||
|
per-package lines `go test` prints: with `-coverpkg` spanning five packages,
|
||||||
|
each of those reports only what that one package's tests reached across the
|
||||||
|
whole set, so all five are far below the real figure.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go tool cover -func=cover.out | Select-Object -Last 1
|
||||||
|
```
|
||||||
|
|
||||||
|
That total was 84.4% at the 2026-08-04 review and 84.1% at the 2026-08-07
|
||||||
|
documentation audit — the number to compare against before concluding that
|
||||||
|
coverage has slipped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Files Overview
|
## Test Files Overview
|
||||||
@@ -74,6 +115,20 @@ Tests schedule parsing and validation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### src/domain/config_test.go
|
||||||
|
|
||||||
|
**Package:** `domain`
|
||||||
|
|
||||||
|
Tests autostart argument helpers and the jobs-list density normalization rule.
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### src/app/service_test.go
|
### src/app/service_test.go
|
||||||
|
|
||||||
**Package:** `app`
|
**Package:** `app`
|
||||||
@@ -84,7 +139,6 @@ Tests `Service` construction and the state-accessor contract.
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
|
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
|
||||||
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
|
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
|
||||||
| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,19 +162,22 @@ 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
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. |
|
| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. |
|
||||||
|
| `TestSetGlobalPausePersistsToConfigFile` | Verifies the paused flag reaches `gosentry.json`, which is what makes the pause survive a restart. |
|
||||||
|
| `TestServiceRebuiltFromPausedStoreStartsPaused` | Verifies a Service built from a paused config starts paused, with the paused next-run text applied before the first tick. |
|
||||||
| `TestRunNowUsesRunnerAndRecords` | Verifies that `RunNow` invokes the runner, records a `RunRecord`, and emits `RunRecorded`. |
|
| `TestRunNowUsesRunnerAndRecords` | Verifies that `RunNow` invokes the runner, records a `RunRecord`, and emits `RunRecorded`. |
|
||||||
| `TestRunNowNotFound` | Verifies that `RunNow` returns an error for an unknown job ID. |
|
| `TestRunNowNotFound` | Verifies that `RunNow` returns an error for an unknown job ID. |
|
||||||
| `TestRunNowRefusedWhileAlreadyRunning` | Verifies that a second concurrent `RunNow` on the same job is rejected while the first is in progress. |
|
| `TestRunNowRefusedWhileAlreadyRunning` | Verifies that a second concurrent `RunNow` on the same job is rejected while the first is in progress. |
|
||||||
| `TestRunNowRefusedWhilePaused` | Verifies that `RunNow` is rejected when the global pause flag is set. |
|
| `TestRunNowAllowedWhilePaused` | Verifies that `RunNow` is allowed when the global pause flag is set (pause stops scheduled runs only). |
|
||||||
| `TestRunDueStartsDueJob` | Verifies that `RunDue` launches a job whose next-run time has passed. |
|
| `TestRunDueStartsDueJob` | Verifies that `RunDue` launches a job whose next-run time has passed. |
|
||||||
| `TestRunDueSkipsJobNotYetDue` | Verifies that `RunDue` does not launch a job that is not yet due. |
|
| `TestRunDueSkipsJobNotYetDue` | Verifies that `RunDue` does not launch a job that is not yet due. |
|
||||||
| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job. |
|
| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job, even with a stale `NextDue` in the past. |
|
||||||
| `TestRunDueDoesNothingWhilePaused` | Verifies that `RunDue` launches nothing when the global pause flag is set. |
|
| `TestRunDueDoesNothingWhilePaused` | Verifies that `RunDue` launches nothing when the global pause flag is set. |
|
||||||
| `TestStartDrivesRunDueOnTick` | Verifies that `Service.Start` wires `RunDue` to the scheduler tick and that each tick advances state. |
|
| `TestStartDrivesRunDueOnTick` | Verifies that `Service.Start` wires `RunDue` to the scheduler tick and that each tick advances state. |
|
||||||
|
|
||||||
@@ -130,10 +187,44 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. |
|
| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. |
|
||||||
| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. |
|
| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. |
|
||||||
|
| `TestHasFileName` | Verifies the jobs-file path check: a file name passes; a trailing separator, `.`, and `..` do not. |
|
||||||
|
| `TestUpdateSettingsWritesJobsToTheNewFile` | Verifies that changing `JobsFile` re-resolves `Paths.JobsPath` and writes the loaded jobs to the new file, creating its folder. |
|
||||||
|
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
|
||||||
|
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
||||||
|
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
||||||
|
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
|
||||||
|
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
|
||||||
|
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
||||||
|
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
||||||
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### src/app/run_test.go
|
||||||
|
|
||||||
|
**Package:** `app`
|
||||||
|
|
||||||
|
Tests overlap policy, sequential execution, run statistics, timeout resolution,
|
||||||
|
and scheduler edge cases using injected `runJob` and `primeDue`.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestUpdateStats` | Verifies aggregate duration math on `JobRuntime`. |
|
||||||
|
| `TestUpdateStatsSkipsZeroDuration` | Verifies zero-duration runs are excluded from averages. |
|
||||||
|
| `TestRunDueParallelStartsAllDueJobs` | Parallel mode: both due jobs enter the runner before either completes. |
|
||||||
|
| `TestRunDueSequentialSerializes` | Sequential mode: job 2 waits until job 1 finishes. |
|
||||||
|
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
|
||||||
|
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish; also covers an empty per-job policy inheriting the global default. |
|
||||||
|
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
|
||||||
|
| `TestRunDueQueueCapsPendingRuns` | Regression: `PendingRuns` stops growing at `maxPendingRuns` instead of accumulating without bound for a job that never keeps up with its schedule. |
|
||||||
|
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
|
||||||
|
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
||||||
|
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
||||||
|
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused, and pausing clears the backlog rather than leaving it to fire a stale deferred run on resume. |
|
||||||
|
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### src/app/events_test.go
|
### src/app/events_test.go
|
||||||
|
|
||||||
**Package:** `app`
|
**Package:** `app`
|
||||||
@@ -143,7 +234,6 @@ Tests the event-emission and observer-subscription machinery.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
|
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
|
||||||
| `TestEmitWithNoObserversIsNoop` | Verifies that emitting an event with no observers does not panic. |
|
|
||||||
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
|
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -158,11 +248,15 @@ 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. |
|
||||||
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
|
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
|
||||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
|
| `TestDisplayFolder` | Verifies that an empty folder string shows "(No folder)". |
|
||||||
|
| `TestDisplayArguments` | Verifies that an empty arguments string shows "(none)". |
|
||||||
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
| `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 that the display index is one-based (job slice index + 1). |
|
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
||||||
|
| `TestDisplayStats` | Verifies statistics line formatting for the details panel. |
|
||||||
|
| `TestDisplayOverlapPolicy` | Verifies per-job vs inherited global overlap policy labels. |
|
||||||
|
| `TestDisplayTimeout` | Verifies the three timeout states read differently in the details panel: `45 s`, `no timeout`, and `… (global default)`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,15 +264,26 @@ Tests display-formatting helpers used by the UI.
|
|||||||
|
|
||||||
**Package:** `storage`
|
**Package:** `storage`
|
||||||
|
|
||||||
Tests JSON round-tripping and default generation.
|
Tests JSON round-tripping, default generation, and backward compatibility.
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `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. |
|
||||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
|
| `TestNormalizeJobsReassignsDuplicateIDs` | Verifies that a hand-edited `jobs.json` with two entries sharing one ID gets the later duplicates reassigned instead of colliding on one runtime. |
|
||||||
|
| `TestResolveConfiguredPathCleansAbsolutePaths` | Verifies (Windows only) that forward-slash and backslash spellings of the same absolute path resolve to the same string. |
|
||||||
|
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
|
||||||
|
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
|
||||||
|
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
||||||
|
| `TestLoadOrCreateConfigPreservesZeroRetentionLimits` | Verifies that `max_log_files` / `max_log_age_days` of 0 read back as 0 ("keep everything") instead of being backfilled to the 100 / 30 defaults — a field the file sets is not the missing-field case. |
|
||||||
|
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
||||||
|
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
|
||||||
|
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
||||||
|
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
||||||
|
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
||||||
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
| `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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,7 +304,7 @@ Tests the timing-loop contract using a fake clock.
|
|||||||
|
|
||||||
**Package:** `runner`
|
**Package:** `runner`
|
||||||
|
|
||||||
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
|
Tests command execution, exit code handling, output capture, and the run timeout.
|
||||||
|
|
||||||
#### Log file tests
|
#### Log file tests
|
||||||
|
|
||||||
@@ -232,21 +337,56 @@ Tests command execution, exit code handling, output capture, and Windows-specifi
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestRunJobFailsOnNonZeroExitCode` | Verifies that a nonzero process exit code results in "Failed" status with an "exit code N" detail. |
|
| `TestRunJobFailsOnNonZeroExitCode` | Verifies that a nonzero process exit code results in "Failed" status with an "exit code N" detail. |
|
||||||
|
|
||||||
|
#### Timeout
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestRunJobTimesOut` | Verifies that a positive timeout kills a long-running command and reports `Timed out after <timeout>`. |
|
||||||
|
| `TestRunJobZeroTimeoutMeansNoTimeout` | Verifies that a non-positive duration runs without a deadline, bounded only by the caller's context. |
|
||||||
|
| `TestRunJobStartOnlyIgnoresTimeout` | Verifies that fire-and-forget jobs run on the untimed context, so the timeout never kills a process the runner is not waiting for. |
|
||||||
|
|
||||||
#### Start-only mode
|
#### Start-only mode
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `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. |
|
||||||
|
|
||||||
#### Utility / Windows invocation
|
---
|
||||||
|
|
||||||
| Test | Platform | Purpose |
|
### src/runner/runner_windows_test.go
|
||||||
|------|----------|---------|
|
|
||||||
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands do not request hidden-window startup. |
|
**Location:** `src/runner/runner_windows_test.go`
|
||||||
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
**Build Tags:** `//go:build windows`
|
||||||
| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
|
||||||
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
Tests the Windows shell invocation and hidden-window flags.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestDirectCommandDoesNotHideWindow` | Verifies that direct executable commands do not request hidden-window startup. |
|
||||||
|
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
||||||
|
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
||||||
|
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||||
|
| `TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension` | Regression: the program path ends at the *earliest* extension match sitting at a token boundary — not the first extension in `.exe`/`.cmd`/`.bat`/`.com` list order, and not a substring inside another word — so a `.bat` wrapper followed by an `.exe` argument still quotes only the wrapper. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/runner/seed_test.go
|
||||||
|
|
||||||
|
**Package:** `runner`
|
||||||
|
|
||||||
|
Tests `SeedStats`, which rebuilds aggregate run statistics from the `.log` files
|
||||||
|
on disk at startup.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestSeedStatsBasic` | Verifies run/fail counts and the last, average, and maximum durations parsed from a job's log headers. |
|
||||||
|
| `TestSeedStatsDurationLessLegacyLog` | Verifies a log written before the `duration` header still counts as a run but is excluded from the duration aggregates, so a missing duration cannot masquerade as a 0 ms run. |
|
||||||
|
| `TestSeedStatsMaxFilesHonoured` | Verifies that only the newest `MaxLogFiles` logs are parsed when the limit is positive. |
|
||||||
|
| `TestSeedStatsMissingDir` | Verifies a missing logs directory yields an empty map rather than an error or a panic. |
|
||||||
|
| `TestSeedStatsUnknownJobProducesNoEntry` | Verifies log files that match no known job are ignored. |
|
||||||
|
| `TestSeedStatsMatchesByJobID` | Verifies logs are matched by the `job_id` header even when two job names sanitize to the same filename. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -259,8 +399,7 @@ Tests log-file cleanup by age and by count.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
|
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
|
||||||
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. |
|
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted and files within the limit are retained. |
|
||||||
| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. |
|
|
||||||
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
|
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
|
||||||
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
|
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
|
||||||
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
|
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
|
||||||
@@ -268,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`
|
||||||
@@ -277,14 +429,14 @@ Tests Windows autostart via shortcuts in the Startup folder.
|
|||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestParseRegistryRunValue` | Verifies that legacy `HKCU\...\Run` entry values are parsed correctly from `reg query` output (for migration/cleanup). |
|
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive, handles quote marks, and matches paths containing spaces. |
|
||||||
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
|
|
||||||
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. |
|
|
||||||
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
|
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
|
||||||
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
|
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
|
||||||
| `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`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -298,7 +450,48 @@ 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. |
|
||||||
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that enabling autostart also removes legacy PySentry service files left by earlier builds. |
|
| `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`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/platform/desktop/desktop_linux_test.go
|
||||||
|
|
||||||
|
**Location:** `src/platform/desktop/desktop_linux_test.go`
|
||||||
|
**Build Tags:** `//go:build linux`
|
||||||
|
|
||||||
|
Tests Linux desktop integration (`.desktop` file and icon under XDG data home).
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestInstallDesktopIntegrationWritesDesktopAndIcon` | Verifies `.desktop` and PNG icon files are written under `$XDG_DATA_HOME`. |
|
||||||
|
| `TestQuoteDesktopExecQuotesPath` | Verifies `Exec=` paths with spaces are shell-quoted. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/platform/filemanager/filemanager_test.go
|
||||||
|
|
||||||
|
**Package:** `filemanager`
|
||||||
|
|
||||||
|
Tests the guards around opening a folder in the desktop file manager. The
|
||||||
|
success path is not tested: it would open a real file manager window.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestOpenRejectsMissingFolder` | Verifies that `Open` reports a missing directory (naming the path) instead of launching a handler. |
|
||||||
|
| `TestOpenRejectsFile` | Verifies that `Open` refuses a path that is a file rather than a directory. |
|
||||||
|
| `TestOpenCommandNamesPlatformHandler` | Verifies the per-platform handler (`explorer` / `xdg-open`, none elsewhere) and that the path is passed as one argument. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -306,17 +499,143 @@ Tests Linux autostart via XDG Desktop Entry files.
|
|||||||
|
|
||||||
**Package:** `ui`
|
**Package:** `ui`
|
||||||
|
|
||||||
Tests pure helper functions in the jobs view (no Fyne widget construction).
|
Tests the Jobs tab: pure filter helpers, and — through Fyne's headless
|
||||||
|
`test.NewApp()` — the geometry and redraw behaviour that only shows up once the
|
||||||
|
widgets are assembled.
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
|
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
|
||||||
| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. |
|
| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. |
|
||||||
| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. |
|
| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. |
|
||||||
| `TestFilteredJobIndexesAll` | Verifies that the "All" filter returns indexes for every job. |
|
| `TestFilteredJobIndexes` | Table: verifies the "All" filter returns every index, a named folder returns only its own jobs, "No folder" matches empty and blank folder fields, and an empty job list yields no indexes. |
|
||||||
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
|
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
|
||||||
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
|
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
|
||||||
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
|
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
|
||||||
|
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
||||||
|
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
||||||
|
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
||||||
|
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
|
||||||
|
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
|
||||||
|
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/jobs_view_state_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
|
||||||
|
folder filter, and the ID-based selection. No Fyne app is built — the state
|
||||||
|
touches no widgets, so these run in milliseconds.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
|
||||||
|
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
|
||||||
|
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
|
||||||
|
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
|
||||||
|
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
|
||||||
|
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
|
||||||
|
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
|
||||||
|
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
|
||||||
|
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/history_view_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests the History tab: the pure activity helpers and the sorted-snapshot and
|
||||||
|
column-width behaviour of the assembled table.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
||||||
|
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
||||||
|
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
||||||
|
| `TestLastJobLogsCapsAndCopies` | Verifies activity panel cap and defensive copy semantics. |
|
||||||
|
| `TestLastJobLogsEmpty` | Verifies nil/empty log input returns an empty slice. |
|
||||||
|
| `TestIndexOfID` | Verifies job lookup by ID returns `-1` when not found. |
|
||||||
|
| `TestHistorySortToggleKeepsRowsInSync` | Regression guard for the cached sorted snapshot: the length callback and the cells must be refilled together, or the row count and the cell contents disagree. |
|
||||||
|
| `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. |
|
||||||
|
| `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. |
|
||||||
|
| `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. |
|
||||||
|
| `TestHistoryLogCapsRecords` | Regression guard for the unbounded History list: the log keeps the newest `maxHistoryRows` records, drops the oldest from the front, and trims a list handed in already over the cap. |
|
||||||
|
| `TestHistoryLogWidthsMatchAFullScan` | Verifies the incremental column widths equal a full rescan while every measured record is still present — the cheaper path must not clip what the old one showed. |
|
||||||
|
| `TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut` | Verifies a column keeps its width after the record that set it is dropped by the cap, since the rows on screen were laid out against it. |
|
||||||
|
| `TestHistoryLogRescansOnThemeChange` | Verifies a theme change falls back to a full rescan, the one case the incremental fold cannot handle because every stored width was measured at the old text size. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/settings_view_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests the Settings tab helpers and the row layout.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestSettingsFolderPath` | Verifies the folder the Logs directory "Open" button targets: blank text yields no path, a relative path resolves against the application directory, an absolute path is used as typed. |
|
||||||
|
| `TestSettingsRowStretchesItsControl` | Verifies the row's centre slot already stretches the control to the column width — the property that made a fixed-width wrapper around it redundant. |
|
||||||
|
| `TestChooseFileAppliesFilter` | Verifies the deduplicated picker opens a dialog both with a nil filter (the command browser) and with a concrete one (`chooseJSONFile`). |
|
||||||
|
| `TestSettingsCaptionsCoverEveryRow` | Verifies every caption used in a row is present in `settingsCaptions` and fits the measured caption column. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/layout_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests the theme-derived sizing helpers in `layout.go`.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
|
||||||
|
| `TestCancelRowOverlapAddsBackOneInnerPadding` | Verifies that `cancelRowOverlap` adds back exactly one inner padding on the top edge only, leaving width and the row below unaffected. |
|
||||||
|
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/theme_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests the branded theme and the stored theme choice.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
|
||||||
|
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
|
||||||
|
| `TestThemeForChoice` | Verifies the GoSentry choice and the empty legacy value yield the branded primary; only the explicit system choice yields Fyne's built-in theme. |
|
||||||
|
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the GoSentry label rather than a blank option. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/mainwindow_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests main view construction with an injected `*app.Service`.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
|
||||||
|
| `TestMainViewRecordStartupAddsHistoryRow` | Verifies the `recordStartup` closure `newMainView` returns appends the startup receipt to History and redraws the table, with the windowed and tray wordings `run.go` selects between. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -334,9 +653,47 @@ Tests pure helper functions in the jobs view (no Fyne widget construction).
|
|||||||
|
|
||||||
6. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
|
6. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
|
||||||
|
|
||||||
|
7. **Regression on serious fixes** — Any fix from an internal review with severity ≥ medium gets a targeted regression test (see `run_test.go` for examples).
|
||||||
|
|
||||||
|
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
|
||||||
|
|
||||||
|
9. **Redundancy is measured, not read** — Before deleting a test as a duplicate, run both in isolation with `-coverprofile` and compare the profiles. Identical coverage alone is *not* grounds for deletion: several kept tests hit the same statements while asserting genuinely different properties (see the table below). Deletion requires identical coverage **and** assertions that are a subset of the survivor's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Look-alike tests that are kept
|
||||||
|
|
||||||
|
Every pair here has an identical coverage profile, so a redundancy pass will
|
||||||
|
flag them again. They were measured under principle 9 and kept because the
|
||||||
|
assertions differ — not because nobody looked.
|
||||||
|
|
||||||
|
| Tests | Why both stay |
|
||||||
|
|-------|---------------|
|
||||||
|
| `TestSetGlobalPausePersistsToConfigFile` / `TestSetGlobalPauseUpdatesRuntimesAndEmits` | The first asserts the flag reaches `gosentry.json`, which is what makes the pause survive a restart; the second asserts the in-memory runtimes and the emitted event. |
|
||||||
|
| `TestRunDueQueueDrainsMultipleOverlaps` / `TestRunDueQueueRerunsAfterFinish` | The first drains three queued occurrences rather than one, so it is the test that would catch a drain loop that fires only once. |
|
||||||
|
| `TestCreateStartupShortcutHandlesCyrillicPath` / `TestCreateStartupShortcutHandlesSpaces` | Non-ASCII paths and paths with spaces are different real-world failure modes for the WScript.Shell COM call. |
|
||||||
|
| `TestRunJobLogFileAllHeaders` / `TestRunJobRecordFields` / `TestRunJobWritesLogFile` | Three different subjects: the log file's headers, the returned `RunRecord`'s fields, and the log file's name and directory. The fixtures differ too — only `TestRunJobWritesLogFile` runs the `Manual` trigger. Merging them into one `RunJob` call was measured and declined: it saves ~90 ms (the three cost 0.14 s combined; the `runner` package's seconds are `TestRunJobTimesOut` and `TestRunJobZeroTimeoutMeansNoTimeout`, which wait on purpose) and would drop the `Manual` path from the header assertions. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Remaining Test Coverage Gaps
|
## Remaining Test Coverage Gaps
|
||||||
|
|
||||||
- GUI integration tests — Fyne widget interaction is not yet tested end-to-end
|
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
|
||||||
- Job history on-disk retrieval — RunRecord file reading is not covered
|
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
|
||||||
|
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
|
### Functions deliberately at 0%
|
||||||
|
|
||||||
|
A coverage run over the non-UI packages reports these as uncovered. All are
|
||||||
|
intentional; none is an oversight to be "fixed" with a test.
|
||||||
|
|
||||||
|
- The real `Clock` — a fake is injected everywhere it is used.
|
||||||
|
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Open` — process entry points, exercised by running the app.
|
||||||
|
- The autostart and desktop-icon wrappers (`app.Service.InstallDesktopIcon`, `AutostartStatus`, `ApplyAutostart`) — OS integration, driven only on a real desktop.
|
||||||
|
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
|
||||||
|
- `app.Service.Config` and `app.Service.Paths` — read only from `src/ui`, which
|
||||||
|
this run excludes, so they are covered by the suite but not by this profile.
|
||||||
|
The same applies to `storage.Store.SaveJobs`: the engine writes through
|
||||||
|
`PrepareSaveJobs`, and the one-shot wrapper is what `OpenStore` uses.
|
||||||
|
- The five `isEvent` marker methods in `app/events.go` — empty bodies that exist
|
||||||
|
only to close the `Event` interface.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 57 KiB |
@@ -18,12 +18,14 @@ fi
|
|||||||
docker build -f Dockerfile -t "$tag" .
|
docker build -f Dockerfile -t "$tag" .
|
||||||
|
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
|
mkdir -p "$(pwd)/.gocache"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
"${docker_user_args[@]}" \
|
"${docker_user_args[@]}" \
|
||||||
-e "VERSION=${version}" \
|
-e "VERSION=${version}" \
|
||||||
-e "OUTPUT=${output}" \
|
-e "OUTPUT=${output}" \
|
||||||
-e "GOCACHE=/tmp/go-build-cache" \
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/src" \
|
||||||
|
-v "$(pwd)/.gocache:/tmp/go-build-cache" \
|
||||||
-w /src \
|
-w /src \
|
||||||
"$tag" \
|
"$tag" \
|
||||||
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
|
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
|
||||||
|
|||||||
@@ -88,11 +88,13 @@ normalize_targets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_in_builder() {
|
run_in_builder() {
|
||||||
|
mkdir -p "${repo_root}/.gocache"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
"${docker_user_args[@]}" \
|
"${docker_user_args[@]}" \
|
||||||
-e "VERSION=${version}" \
|
-e "VERSION=${version}" \
|
||||||
-e "GOCACHE=/tmp/go-build-cache" \
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
-v "${repo_root}:/src" \
|
-v "${repo_root}:/src" \
|
||||||
|
-v "${repo_root}/.gocache:/tmp/go-build-cache" \
|
||||||
-w /src \
|
-w /src \
|
||||||
"$tag" \
|
"$tag" \
|
||||||
bash -c "$1"
|
bash -c "$1"
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build and package every release artifact on a Linux host that already has the
|
||||||
|
# cross toolchain installed (native gcc + X11/OpenGL headers, the aarch64 cross
|
||||||
|
# compiler, and the MinGW-w64 toolchain for the Windows GUI binary). This is the
|
||||||
|
# non-Docker counterpart to scripts/build-release-linux.sh: the CI workflows in
|
||||||
|
# .github/ and .forgejo/ install those packages directly on the runner and then
|
||||||
|
# call this script, so the exact build/package commands live in one place and do
|
||||||
|
# not drift between the two forges.
|
||||||
|
#
|
||||||
|
# The build flags mirror the other scripts intentionally: -trimpath strips local
|
||||||
|
# paths, -s -w drops symbol/debug tables to shrink the binaries, -H=windowsgui
|
||||||
|
# suppresses the console window on Windows, and -X injects the version so the
|
||||||
|
# GUI and artifact names agree.
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
# VERSION can be provided by CI (for a tagged release the workflow passes the tag
|
||||||
|
# without its leading "v"). Fall back to the source of truth in version.go so the
|
||||||
|
# script also works for a plain local invocation.
|
||||||
|
version="${VERSION:-$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')}"
|
||||||
|
version="${version:-0.0.0-dev}"
|
||||||
|
ldflags="-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}"
|
||||||
|
|
||||||
|
echo "Building GoSentry ${version} release artifacts"
|
||||||
|
mkdir -p dist/linux dist/windows
|
||||||
|
|
||||||
|
# --- Linux amd64 -----------------------------------------------------------
|
||||||
|
echo "==> linux/amd64"
|
||||||
|
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -buildvcs=false -trimpath -ldflags "$ldflags" \
|
||||||
|
-o "dist/linux/gosentry-${version}-linux-amd64" ./cmd/gosentry
|
||||||
|
|
||||||
|
# --- Linux arm64 (cross compiled) ------------------------------------------
|
||||||
|
echo "==> linux/arm64"
|
||||||
|
CC=aarch64-linux-gnu-gcc \
|
||||||
|
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
|
||||||
|
CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" \
|
||||||
|
CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" \
|
||||||
|
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
|
||||||
|
go build -buildvcs=false -trimpath -ldflags "$ldflags" \
|
||||||
|
-o "dist/linux/gosentry-${version}-linux-arm64" ./cmd/gosentry
|
||||||
|
|
||||||
|
# --- Windows amd64 (cross compiled with MinGW) -----------------------------
|
||||||
|
echo "==> windows/amd64"
|
||||||
|
# windres embeds the .ico into the PE resource so Explorer/taskbar show the icon.
|
||||||
|
# The .syso is suffixed windows_amd64, so Go only links it into the Windows build
|
||||||
|
# and ignores it for the Linux targets above.
|
||||||
|
x86_64-w64-mingw32-windres -O coff \
|
||||||
|
-o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc
|
||||||
|
CC=x86_64-w64-mingw32-gcc \
|
||||||
|
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \
|
||||||
|
go build -buildvcs=false -trimpath -ldflags "-H=windowsgui ${ldflags}" \
|
||||||
|
-o "dist/windows/gosentry-${version}-windows-amd64.exe" ./cmd/gosentry
|
||||||
|
|
||||||
|
# --- Package ---------------------------------------------------------------
|
||||||
|
# Each archive holds the executable plus the top-level README and CHANGELOG,
|
||||||
|
# flattened to the archive root so a user can extract straight into any folder.
|
||||||
|
# This matches the layout produced by package-linux.sh / package-windows.bat.
|
||||||
|
package_linux() {
|
||||||
|
local arch="$1"
|
||||||
|
local binary="dist/linux/gosentry-${version}-linux-${arch}"
|
||||||
|
local tarball="dist/linux/gosentry-${version}-linux-${arch}.tar.gz"
|
||||||
|
local staging="dist/linux/_staging-${arch}"
|
||||||
|
|
||||||
|
rm -rf "$staging"
|
||||||
|
mkdir -p "$staging"
|
||||||
|
cp "$binary" "$staging/gosentry"
|
||||||
|
cp README.md "$staging/README.md"
|
||||||
|
cp docs/CHANGELOG.md "$staging/CHANGELOG.md"
|
||||||
|
tar -czf "$tarball" -C "$staging" .
|
||||||
|
rm -rf "$staging"
|
||||||
|
echo "Packaged $tarball"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_windows() {
|
||||||
|
local binary="dist/windows/gosentry-${version}-windows-amd64.exe"
|
||||||
|
local zipfile="gosentry-${version}-windows-amd64.zip"
|
||||||
|
local staging="dist/windows/_staging-amd64"
|
||||||
|
|
||||||
|
rm -rf "$staging"
|
||||||
|
mkdir -p "$staging"
|
||||||
|
cp "$binary" "$staging/gosentry.exe"
|
||||||
|
cp README.md "$staging/README.md"
|
||||||
|
cp docs/CHANGELOG.md "$staging/CHANGELOG.md"
|
||||||
|
# -j flattens: files land at the zip root with no staging path prefix.
|
||||||
|
( cd "$staging" && zip -j -q "../${zipfile}" ./* )
|
||||||
|
rm -rf "$staging"
|
||||||
|
echo "Packaged dist/windows/${zipfile}"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_linux amd64
|
||||||
|
package_linux arm64
|
||||||
|
package_windows
|
||||||
|
|
||||||
|
echo "Release artifacts:"
|
||||||
|
find dist/linux dist/windows -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 —
|
||||||
@@ -41,6 +44,17 @@ type SchedulerStateChanged struct {
|
|||||||
Paused bool
|
Paused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobsLoaded signals that the whole job list was replaced by the contents of a
|
||||||
|
// jobs file the user selected in Settings. It carries the path and job count
|
||||||
|
// because that is what the user needs to see confirmed — the switch happens
|
||||||
|
// without a prompt, and the previous list is no longer on screen to compare
|
||||||
|
// against. Observers that render jobs should re-read them through the Service;
|
||||||
|
// a broad JobChanged is emitted alongside for exactly that.
|
||||||
|
type JobsLoaded struct {
|
||||||
|
Path string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
// ErrorOccurred signals a background error that could not be returned to a
|
// ErrorOccurred signals a background error that could not be returned to a
|
||||||
// caller — typically a failed save or cleanup after an async run. The UI
|
// caller — typically a failed save or cleanup after an async run. The UI
|
||||||
// surfaces it in the History tab so the user is not silently left with
|
// surfaces it in the History tab so the user is not silently left with
|
||||||
@@ -50,6 +64,7 @@ type ErrorOccurred struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (JobChanged) isEvent() {}
|
func (JobChanged) isEvent() {}
|
||||||
|
func (JobsLoaded) isEvent() {}
|
||||||
func (RunRecorded) isEvent() {}
|
func (RunRecorded) isEvent() {}
|
||||||
func (SchedulerStateChanged) isEvent() {}
|
func (SchedulerStateChanged) isEvent() {}
|
||||||
func (ErrorOccurred) isEvent() {}
|
func (ErrorOccurred) isEvent() {}
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmitWithNoObserversIsNoop(t *testing.T) {
|
|
||||||
svc := newTestService(nil)
|
|
||||||
// Must not panic with an empty observer list.
|
|
||||||
svc.emit(JobChanged{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observers may read Service state from within OnEvent without deadlocking,
|
// Observers may read Service state from within OnEvent without deadlocking,
|
||||||
// because emit is called outside the state lock.
|
// because emit is called outside the state lock.
|
||||||
func TestObserverCanReadServiceState(t *testing.T) {
|
func TestObserverCanReadServiceState(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -102,6 +117,24 @@ func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) str
|
|||||||
return string(globalPolicy) + " (global default)"
|
return string(globalPolicy) + " (global default)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayTimeout formats a job's effective run timeout for the details panel.
|
||||||
|
// When the job sets its own TimeoutSeconds it is shown as-is, with an explicit 0
|
||||||
|
// rendered as "no timeout"; when unset (nil), the global default is shown with
|
||||||
|
// "(global default)" appended, mirroring DisplayOverlapPolicy. A non-positive
|
||||||
|
// global default means no timeout at all.
|
||||||
|
func DisplayTimeout(job domain.Job, globalDefault int) string {
|
||||||
|
if job.TimeoutSeconds != nil {
|
||||||
|
if *job.TimeoutSeconds <= 0 {
|
||||||
|
return "no timeout"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d s", *job.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if globalDefault <= 0 {
|
||||||
|
return "no timeout (global default)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d s (global default)", globalDefault)
|
||||||
|
}
|
||||||
|
|
||||||
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
||||||
// or 0 if not found.
|
// or 0 if not found.
|
||||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -151,3 +162,33 @@ func TestEventLine(t *testing.T) {
|
|||||||
t.Errorf("EventLine blank trigger = %q, want %q", got, want)
|
t.Errorf("EventLine blank trigger = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisplayOverlapPolicy(t *testing.T) {
|
||||||
|
global := domain.OverlapPolicyQueue
|
||||||
|
jobOwn := domain.Job{OverlapPolicy: string(domain.OverlapPolicySkip)}
|
||||||
|
if got, want := DisplayOverlapPolicy(jobOwn, global), "skip"; got != want {
|
||||||
|
t.Errorf("per-job policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
inherit := domain.Job{OverlapPolicy: ""}
|
||||||
|
if got, want := DisplayOverlapPolicy(inherit, global), "queue (global default)"; got != want {
|
||||||
|
t.Errorf("inherited policy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayTimeout(t *testing.T) {
|
||||||
|
own := domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(45)}
|
||||||
|
if got, want := DisplayTimeout(own, 30), "45 s"; got != want {
|
||||||
|
t.Errorf("per-job timeout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
none := domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(0)}
|
||||||
|
if got, want := DisplayTimeout(none, 30), "no timeout"; got != want {
|
||||||
|
t.Errorf("explicit per-job zero timeout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
inherit := domain.Job{TimeoutSeconds: nil}
|
||||||
|
if got, want := DisplayTimeout(inherit, 30), "30 s (global default)"; got != want {
|
||||||
|
t.Errorf("inherited timeout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := DisplayTimeout(inherit, 0), "no timeout (global default)"; got != want {
|
||||||
|
t.Errorf("inherited infinite timeout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package app
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
||||||
@@ -41,15 +39,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
|
||||||
@@ -85,10 +91,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})
|
||||||
@@ -111,10 +117,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})
|
||||||
@@ -146,249 +152,20 @@ 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})
|
||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetGlobalPause flips the global pause that gates scheduled execution.
|
|
||||||
// Manual "Run now" remains available while paused. Each enabled job's next-run
|
|
||||||
// text reflects the new state immediately so the list view is understandable
|
|
||||||
// before the next tick. A "Paused"/"Resumed" scheduler activity record and a
|
|
||||||
// SchedulerStateChanged event are emitted.
|
|
||||||
func (s *Service) SetGlobalPause(paused bool) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.paused = paused
|
|
||||||
s.store.Config.Paused = paused
|
|
||||||
now := time.Now()
|
|
||||||
for index := range s.jobs {
|
|
||||||
job := &s.jobs[index]
|
|
||||||
runtime := s.runtimeForLocked(job)
|
|
||||||
s.refreshNextRunFromLocked(job, runtime, now)
|
|
||||||
}
|
|
||||||
err := s.store.SaveConfig()
|
|
||||||
if err == nil {
|
|
||||||
err = s.store.SaveJobs(s.jobs)
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
|
||||||
if paused {
|
|
||||||
state, detail = "Paused", "All job execution paused"
|
|
||||||
}
|
|
||||||
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
|
||||||
s.emit(SchedulerStateChanged{Paused: paused})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
|
||||||
// notifications for failed job runs. It reads the config under mu so it is
|
|
||||||
// safe to call from any goroutine.
|
|
||||||
func (s *Service) ShouldNotifyOnFailure() bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.store.Config.NotifyOnFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSettings validates and persists a new application configuration. The
|
|
||||||
// loaded jobs are re-saved because the jobs directory may have changed, and log
|
|
||||||
// cleanup runs so a tightened retention policy takes effect immediately.
|
|
||||||
func (s *Service) UpdateSettings(config domain.Config) error {
|
|
||||||
if err := validateConfig(config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.store.Config = config
|
|
||||||
if err := s.store.SaveConfig(); err != nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
|
||||||
// the (possibly new) jobs directory and cleanup targets the new logs dir.
|
|
||||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logsDir := s.store.Paths.LogsDir
|
|
||||||
maxFiles := s.store.Config.MaxLogFiles
|
|
||||||
maxAge := s.store.Config.MaxLogAgeDays
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
|
||||||
// honoring enabled/paused state. The caller must hold mu.
|
|
||||||
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
|
||||||
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
|
||||||
// time, used when one timestamp should drive a whole batch (e.g. a global
|
|
||||||
// pause). The caller must hold mu.
|
|
||||||
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
|
||||||
if !job.Enabled {
|
|
||||||
runtime.NextRun = "Paused"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.paused {
|
|
||||||
runtime.NextRun = "Scheduler paused"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.prepareNextRunLocked(job, runtime, from)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareNextRunLocked computes the concrete next-due time from the cached
|
|
||||||
// schedule. A missing cache entry means the schedule string was unparseable.
|
|
||||||
// The caller must hold mu.
|
|
||||||
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
|
||||||
sched, ok := s.schedules[job.ID]
|
|
||||||
if !ok {
|
|
||||||
runtime.NextRun = "Invalid schedule"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runtime.NextDue = sched.Next(from)
|
|
||||||
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
|
||||||
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
|
||||||
// two apart. The caller must hold mu.
|
|
||||||
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
|
||||||
sched, err := domain.Parse(job.Schedule)
|
|
||||||
if err != nil {
|
|
||||||
delete(s.schedules, job.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.schedules[job.ID] = sched
|
|
||||||
}
|
|
||||||
|
|
||||||
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
|
||||||
// given ID, or nil. The caller must hold mu.
|
|
||||||
func (s *Service) findByIDLocked(id int) *domain.Job {
|
|
||||||
index := s.indexByIDLocked(id)
|
|
||||||
if index < 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &s.jobs[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
|
||||||
// The caller must hold mu.
|
|
||||||
func (s *Service) indexByIDLocked(id int) int {
|
|
||||||
for index := range s.jobs {
|
|
||||||
if s.jobs[index].ID == id {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
|
||||||
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
|
||||||
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
|
|
||||||
runtime, ok := s.runtimes[job.ID]
|
|
||||||
if !ok || runtime == nil {
|
|
||||||
runtime = domain.NewRuntime(*job)
|
|
||||||
s.runtimes[job.ID] = runtime
|
|
||||||
}
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
|
||||||
// caller must hold mu.
|
|
||||||
func (s *Service) nextIDLocked() int {
|
|
||||||
next := 1
|
|
||||||
for index := range s.jobs {
|
|
||||||
if s.jobs[index].ID >= next {
|
|
||||||
next = s.jobs[index].ID + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// prependLog adds a record to the front of a runtime's activity list and caps
|
|
||||||
// its length so it cannot grow without bound.
|
|
||||||
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
|
||||||
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
|
||||||
if len(runtime.Logs) > maxJobLogs {
|
|
||||||
runtime.Logs = runtime.Logs[:maxJobLogs]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiRecord builds an activity record for a user/Service action, using the same
|
|
||||||
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
|
||||||
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
|
||||||
return domain.RunRecord{
|
|
||||||
Time: time.Now().Format(timestampLayout),
|
|
||||||
JobID: jobID,
|
|
||||||
JobName: jobName,
|
|
||||||
Trigger: "UI",
|
|
||||||
State: state,
|
|
||||||
Detail: detail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeJob trims user-entered fields and applies the same defaults the job
|
|
||||||
// dialog used, so callers do not have to.
|
|
||||||
func normalizeJob(job *domain.Job) {
|
|
||||||
job.Name = strings.TrimSpace(job.Name)
|
|
||||||
job.Folder = strings.TrimSpace(job.Folder)
|
|
||||||
job.Schedule = strings.TrimSpace(job.Schedule)
|
|
||||||
job.Command = strings.TrimSpace(job.Command)
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateJob enforces the minimum executable definition: name, schedule, and
|
|
||||||
// command must be present. Folder is optional. The schedule string itself is not
|
|
||||||
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
|
||||||
// next-run, matching the prior behavior.
|
|
||||||
func validateJob(job domain.Job) error {
|
|
||||||
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
|
||||||
return errors.New("name, schedule, and command are required")
|
|
||||||
}
|
|
||||||
policy := strings.TrimSpace(job.OverlapPolicy)
|
|
||||||
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
|
||||||
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateConfig rejects settings that would break persistence or cleanup.
|
|
||||||
func validateConfig(config domain.Config) error {
|
|
||||||
if strings.TrimSpace(config.JobsDir) == "" {
|
|
||||||
return errors.New("jobs directory is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
|
||||||
return errors.New("logs directory is required")
|
|
||||||
}
|
|
||||||
if config.MaxLogFiles <= 0 {
|
|
||||||
return errors.New("max log files must be a positive number")
|
|
||||||
}
|
|
||||||
if config.MaxLogAgeDays <= 0 {
|
|
||||||
return errors.New("max log age days must be a positive number")
|
|
||||||
}
|
|
||||||
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
|
||||||
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
|
||||||
}
|
|
||||||
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
|
||||||
return errors.New("overlap policy must be 'skip' or 'queue'")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
||||||
|
// honoring enabled/paused state. The caller must hold mu.
|
||||||
|
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
||||||
|
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
||||||
|
// time, used when one timestamp should drive a whole batch (e.g. a global
|
||||||
|
// pause). The caller must hold mu.
|
||||||
|
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||||
|
if !job.Enabled {
|
||||||
|
runtime.NextRun = "Paused"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.paused {
|
||||||
|
runtime.NextRun = "Scheduler paused"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.prepareNextRunLocked(job, runtime, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareNextRunLocked computes the concrete next-due time from the cached
|
||||||
|
// schedule. A missing cache entry means the schedule string was unparseable.
|
||||||
|
// The caller must hold mu.
|
||||||
|
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||||
|
sched, ok := s.schedules[job.ID]
|
||||||
|
if !ok {
|
||||||
|
runtime.NextRun = "Invalid schedule"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtime.NextDue = sched.Next(from)
|
||||||
|
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
||||||
|
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
||||||
|
// two apart. The caller must hold mu.
|
||||||
|
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
||||||
|
sched, err := domain.Parse(job.Schedule)
|
||||||
|
if err != nil {
|
||||||
|
delete(s.schedules, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.schedules[job.ID] = sched
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
||||||
|
// given ID, or nil. The caller must hold mu.
|
||||||
|
func (s *Service) findByIDLocked(id int) *domain.Job {
|
||||||
|
index := s.indexByIDLocked(id)
|
||||||
|
if index < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s.jobs[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
||||||
|
// The caller must hold mu.
|
||||||
|
func (s *Service) indexByIDLocked(id int) int {
|
||||||
|
for index := range s.jobs {
|
||||||
|
if s.jobs[index].ID == id {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
||||||
|
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
||||||
|
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
|
||||||
|
runtime, ok := s.runtimes[job.ID]
|
||||||
|
if !ok || runtime == nil {
|
||||||
|
runtime = domain.NewRuntime(*job)
|
||||||
|
s.runtimes[job.ID] = runtime
|
||||||
|
}
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
||||||
|
// caller must hold mu.
|
||||||
|
func (s *Service) nextIDLocked() int {
|
||||||
|
next := 1
|
||||||
|
for index := range s.jobs {
|
||||||
|
if s.jobs[index].ID >= next {
|
||||||
|
next = s.jobs[index].ID + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// prependLog adds a record to the front of a runtime's activity list and caps
|
||||||
|
// its length so it cannot grow without bound.
|
||||||
|
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
||||||
|
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
||||||
|
if len(runtime.Logs) > maxJobLogs {
|
||||||
|
runtime.Logs = runtime.Logs[:maxJobLogs]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiRecord builds an activity record for a user/Service action, using the same
|
||||||
|
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
||||||
|
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
||||||
|
return domain.RunRecord{
|
||||||
|
Time: time.Now().Format(timestampLayout),
|
||||||
|
JobID: jobID,
|
||||||
|
JobName: jobName,
|
||||||
|
Trigger: "UI",
|
||||||
|
State: state,
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetGlobalPause flips the global pause that gates scheduled execution.
|
||||||
|
// Manual "Run now" remains available while paused. Each enabled job's next-run
|
||||||
|
// text reflects the new state immediately so the list view is understandable
|
||||||
|
// before the next tick. A "Paused"/"Resumed" scheduler activity record and a
|
||||||
|
// SchedulerStateChanged event are emitted.
|
||||||
|
func (s *Service) SetGlobalPause(paused bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.paused = paused
|
||||||
|
s.store.Config.Paused = paused
|
||||||
|
now := time.Now()
|
||||||
|
for index := range s.jobs {
|
||||||
|
job := &s.jobs[index]
|
||||||
|
runtime := s.runtimeForLocked(job)
|
||||||
|
if paused {
|
||||||
|
// A "queue" backlog counts occurrences missed *while paused is off*; once
|
||||||
|
// paused, none of those correspond to anything the user would expect
|
||||||
|
// replayed on resume, so drop it rather than letting a stale counter fire
|
||||||
|
// a deferred run for an occurrence from before the pause.
|
||||||
|
runtime.PendingRuns = 0
|
||||||
|
}
|
||||||
|
s.refreshNextRunFromLocked(job, runtime, now)
|
||||||
|
}
|
||||||
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
|
if paused {
|
||||||
|
state, detail = "Paused", "All job execution paused"
|
||||||
|
}
|
||||||
|
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
||||||
|
s.emit(SchedulerStateChanged{Paused: paused})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJobListView persists the Jobs list density preference. Unlike
|
||||||
|
// SetGlobalPause this touches nothing but the config: no job changed, so there
|
||||||
|
// is no SaveJobs, and no event is emitted — the choice is presentational and the
|
||||||
|
// Jobs view refreshes its own list, whereas an event would trigger a pointless
|
||||||
|
// whole-window refresh. Anything that is not "compact" is stored as detailed so
|
||||||
|
// the file never gains an unrecognised value.
|
||||||
|
func (s *Service) SetJobListView(view domain.JobListView) error {
|
||||||
|
if !view.IsCompact() {
|
||||||
|
view = domain.JobListViewDetailed
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.store.Config.JobListView == view {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.store.Config.JobListView = view
|
||||||
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
|
s.mu.Unlock()
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||||
|
// notifications for failed job runs. It reads the config under mu so it is
|
||||||
|
// safe to call from any goroutine.
|
||||||
|
func (s *Service) ShouldNotifyOnFailure() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Config.NotifyOnFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings validates and persists a new application configuration. The
|
||||||
|
// loaded jobs are re-saved because the jobs file may have changed, and log
|
||||||
|
// cleanup runs so a tightened retention policy takes effect immediately.
|
||||||
|
//
|
||||||
|
// Pointing the config at a different jobs file that already exists adopts that
|
||||||
|
// file: its jobs replace the loaded ones, which is the only way the user can
|
||||||
|
// switch between job lists. A path with no file there yet receives the current
|
||||||
|
// jobs instead, which is how the jobs file is renamed or relocated. Adoption
|
||||||
|
// discards all runtime state, so it is refused while a job is running.
|
||||||
|
func (s *Service) UpdateSettings(config domain.Config) error {
|
||||||
|
if err := validateConfig(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The path is stored exactly as it is resolved, so a hand-typed value with
|
||||||
|
// stray spaces cannot make the saved setting and the file in use disagree.
|
||||||
|
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// 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
|
||||||
|
running := s.anyRunningLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if switching && running {
|
||||||
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
|
}
|
||||||
|
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
||||||
|
// new config points at, before anything is written and while no lock is held:
|
||||||
|
// both are file I/O, and SeedStats opens every log in the directory. A file
|
||||||
|
// that cannot be parsed leaves both the config and the current jobs untouched.
|
||||||
|
var adopted []domain.Job
|
||||||
|
var seeds map[int]runner.SeededStats
|
||||||
|
if switching {
|
||||||
|
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
adopted = jobs
|
||||||
|
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// The guard above was evaluated before the reads, off the lock, so re-check
|
||||||
|
// it: a scheduled run may have started in the meantime, and adoption drops
|
||||||
|
// every runtime.
|
||||||
|
if switching && s.anyRunningLocked() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
|
}
|
||||||
|
s.store.Config = config
|
||||||
|
saveConfig := s.store.PrepareSaveConfig()
|
||||||
|
if adopted != nil {
|
||||||
|
s.adoptJobsLocked(adopted)
|
||||||
|
s.applySeededStatsLocked(seeds)
|
||||||
|
}
|
||||||
|
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
||||||
|
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
||||||
|
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
||||||
|
// that normalization filled in, exactly as loading them at startup would. The
|
||||||
|
// jobs write is skipped when the config write fails, because both writes run
|
||||||
|
// in the order prepared and stop at the first error.
|
||||||
|
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
||||||
|
loaded := len(s.jobs)
|
||||||
|
logsDir := s.store.Paths.LogsDir
|
||||||
|
maxFiles := s.store.Config.MaxLogFiles
|
||||||
|
maxAge := s.store.Config.MaxLogAgeDays
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
saveErr := save()
|
||||||
|
if adopted != nil {
|
||||||
|
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
||||||
|
// History which file those jobs came from, since nothing was asked. Both
|
||||||
|
// are emitted even when the write failed: the adopted jobs are already the
|
||||||
|
// in-memory list, and a job list the user cannot see would be worse than
|
||||||
|
// the error they are about to be shown.
|
||||||
|
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
||||||
|
s.emit(JobChanged{})
|
||||||
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
|
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -27,7 +29,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
|
|||||||
JobsPath: filepath.Join(dir, "jobs.json"),
|
JobsPath: filepath.Join(dir, "jobs.json"),
|
||||||
LogsDir: filepath.Join(dir, "logs"),
|
LogsDir: filepath.Join(dir, "logs"),
|
||||||
},
|
},
|
||||||
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip},
|
Config: domain.Config{JobsFile: "jobs.json", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
|
||||||
}
|
}
|
||||||
return NewService(store, jobs)
|
return NewService(store, jobs)
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,13 @@ func TestCreateJobValidates(t *testing.T) {
|
|||||||
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
|
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
|
||||||
t.Error("expected error for invalid overlap policy")
|
t.Error("expected error for invalid overlap policy")
|
||||||
}
|
}
|
||||||
|
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(-1)}); err == nil {
|
||||||
|
t.Error("expected error for negative per-job timeout")
|
||||||
|
}
|
||||||
|
// An explicit 0 is a valid choice ("no timeout"), not a rejected one.
|
||||||
|
if _, err := svc.CreateJob(domain.Job{Name: "Zero", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(0)}); err != nil {
|
||||||
|
t.Errorf("explicit zero per-job timeout should be accepted: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
||||||
@@ -210,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},
|
||||||
@@ -250,7 +276,7 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
done := make(chan domain.RunRecord, 1)
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
if trigger != "Manual" {
|
if trigger != "Manual" {
|
||||||
t.Errorf("trigger = %q, want Manual", trigger)
|
t.Errorf("trigger = %q, want Manual", trigger)
|
||||||
}
|
}
|
||||||
@@ -298,7 +324,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- struct{}{}
|
entered <- struct{}{}
|
||||||
<-release
|
<-release
|
||||||
@@ -341,7 +367,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
select {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -366,7 +392,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
done := make(chan domain.RunRecord, 1)
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
if trigger != "Schedule" {
|
if trigger != "Schedule" {
|
||||||
t.Errorf("trigger = %q, want Schedule", trigger)
|
t.Errorf("trigger = %q, want Schedule", trigger)
|
||||||
}
|
}
|
||||||
@@ -397,7 +423,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
|
|||||||
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
var ran int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}, nil
|
return domain.RunRecord{}, nil
|
||||||
}
|
}
|
||||||
@@ -418,7 +444,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
return domain.RunRecord{State: "Success"}, nil
|
return domain.RunRecord{State: "Success"}, nil
|
||||||
}
|
}
|
||||||
@@ -442,7 +468,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
|||||||
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
var ran int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}, nil
|
return domain.RunRecord{}, nil
|
||||||
}
|
}
|
||||||
@@ -472,7 +498,7 @@ func TestStartDrivesRunDueOnTick(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
select {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -496,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
|
||||||
@@ -507,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,10 +559,12 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
mutate func(c *domain.Config)
|
mutate func(c *domain.Config)
|
||||||
}{
|
}{
|
||||||
{"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
|
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
|
||||||
|
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
|
||||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
{"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 }},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -536,6 +577,265 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasFileName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"jobs.json", true},
|
||||||
|
{filepath.Join("data", "team.json"), true},
|
||||||
|
{"jobs" + string(filepath.Separator), false},
|
||||||
|
{"data/", false},
|
||||||
|
{".", false},
|
||||||
|
{"..", false},
|
||||||
|
{string(filepath.Separator), false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := hasFileName(tc.path); got != tc.want {
|
||||||
|
t.Errorf("hasFileName(%q) = %v, want %v", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming or relocating the jobs file writes the loaded jobs to the new path,
|
||||||
|
// which is what makes the Settings change take effect without a restart.
|
||||||
|
func TestUpdateSettingsWritesJobsToTheNewFile(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Kept", Schedule: "@every 1m", Command: "echo hi", Enabled: true}})
|
||||||
|
|
||||||
|
config := svc.store.Config
|
||||||
|
config.JobsFile = filepath.Join("data", "team-jobs.json")
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moved := filepath.Join(svc.store.Paths.AppDir, "data", "team-jobs.json")
|
||||||
|
if svc.store.Paths.JobsPath != moved {
|
||||||
|
t.Errorf("JobsPath: got %q, want %q", svc.store.Paths.JobsPath, moved)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(moved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read moved jobs file: %v", err)
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
t.Fatalf("unmarshal moved jobs file: %v", err)
|
||||||
|
}
|
||||||
|
if len(file.Jobs) != 1 || file.Jobs[0].Name != "Kept" {
|
||||||
|
t.Errorf("moved jobs file: got %+v, want the single 'Kept' job", file.Jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointing Settings at a jobs file that already exists must adopt that file:
|
||||||
|
// its jobs replace the loaded ones instead of being overwritten by them. This is
|
||||||
|
// the only way the user can switch between job lists, so the file's contents
|
||||||
|
// win, the job list is rebuilt around them, and History is told where they came
|
||||||
|
// from.
|
||||||
|
func TestUpdateSettingsAdoptsExistingJobsFile(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||||
|
rec := &recorder{}
|
||||||
|
svc.Subscribe(rec)
|
||||||
|
|
||||||
|
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
|
||||||
|
existing := domain.JobsFile{Jobs: []domain.Job{
|
||||||
|
{ID: 4, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
|
||||||
|
{Name: "Needs an ID", Schedule: "@every 9m", Command: "echo second", Enabled: false},
|
||||||
|
}}
|
||||||
|
data, err := json.Marshal(existing)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := svc.Jobs()
|
||||||
|
if len(jobs) != 2 || jobs[0].Name != "Adopted" {
|
||||||
|
t.Fatalf("jobs after adoption: got %+v, want the two jobs from the selected file", jobs)
|
||||||
|
}
|
||||||
|
// The adopted jobs must be fully live, not just listed: runtime and parsed
|
||||||
|
// schedule are rebuilt for the IDs the file brought (including the one
|
||||||
|
// normalization had to assign).
|
||||||
|
for _, job := range jobs {
|
||||||
|
if svc.Runtime(job.ID) == nil {
|
||||||
|
t.Errorf("job %d (%q) has no runtime after adoption", job.ID, job.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.Runtime(1) != nil {
|
||||||
|
t.Error("runtime of the replaced job should be gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded []JobsLoaded
|
||||||
|
for _, e := range rec.events {
|
||||||
|
if jl, ok := e.(JobsLoaded); ok {
|
||||||
|
loaded = append(loaded, jl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(loaded) != 1 || loaded[0].Path != shared || loaded[0].Count != 2 {
|
||||||
|
t.Errorf("JobsLoaded events: got %+v, want one for %q with 2 jobs", loaded, shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path with no file behind it is the "rename or relocate" case: the current
|
||||||
|
// jobs are written there rather than an empty list being adopted.
|
||||||
|
func TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||||
|
|
||||||
|
config := svc.store.Config
|
||||||
|
config.JobsFile = filepath.Join("moved", "jobs.json")
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := svc.Jobs()
|
||||||
|
if len(jobs) != 1 || jobs[0].Name != "Local" {
|
||||||
|
t.Fatalf("jobs after the move: got %+v, want the original job", jobs)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(svc.store.Paths.AppDir, "moved", "jobs.json")); err != nil {
|
||||||
|
t.Errorf("jobs should have been written to the new path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adoption throws away every runtime, including the state of a run in flight,
|
||||||
|
// and a finishing run would then write its result onto whichever job inherited
|
||||||
|
// its ID. Refusing the switch is what keeps that from happening.
|
||||||
|
func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Long", Schedule: "@every 1h", Command: "echo long", Enabled: true}})
|
||||||
|
entered := make(chan int, 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
|
entered <- job.ID
|
||||||
|
<-release
|
||||||
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
|
}
|
||||||
|
done := completions(svc)
|
||||||
|
|
||||||
|
if err := svc.RunNow(1); err != nil {
|
||||||
|
t.Fatalf("RunNow: %v", err)
|
||||||
|
}
|
||||||
|
<-entered
|
||||||
|
|
||||||
|
config := svc.store.Config
|
||||||
|
config.JobsFile = filepath.Join("elsewhere", "jobs.json")
|
||||||
|
if err := svc.UpdateSettings(config); err == nil {
|
||||||
|
t.Error("expected the jobs-file switch to be refused while a job is running")
|
||||||
|
}
|
||||||
|
if svc.store.Config.JobsFile == config.JobsFile {
|
||||||
|
t.Error("the refused switch must not have been persisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A setting that does not touch the jobs file still saves during a run.
|
||||||
|
unrelated := svc.store.Config
|
||||||
|
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
|
||||||
|
if err := svc.UpdateSettings(unrelated); err != nil {
|
||||||
|
t.Errorf("unrelated setting should still save during a run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
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++ {
|
||||||
@@ -582,6 +882,63 @@ func TestSetGlobalPausePersistsToConfigFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetJobListViewPersistsToConfigFile(t *testing.T) {
|
||||||
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
|
readConfig := func(stage string) domain.Config {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config file %s: %v", stage, err)
|
||||||
|
}
|
||||||
|
var cfg domain.Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshalling config %s: %v", stage, err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
|
||||||
|
t.Fatalf("SetJobListView(compact): %v", err)
|
||||||
|
}
|
||||||
|
if got := readConfig("after compact").JobListView; got != domain.JobListViewCompact {
|
||||||
|
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewCompact)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SetJobListView(domain.JobListViewDetailed); err != nil {
|
||||||
|
t.Fatalf("SetJobListView(detailed): %v", err)
|
||||||
|
}
|
||||||
|
if got := readConfig("after detailed").JobListView; got != domain.JobListViewDetailed {
|
||||||
|
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewDetailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetJobListViewNormalizesUnknownValue guards the config file against
|
||||||
|
// gaining a value no reader understands: anything but "compact" is stored as
|
||||||
|
// "detailed".
|
||||||
|
func TestSetJobListViewNormalizesUnknownValue(t *testing.T) {
|
||||||
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
|
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
|
||||||
|
t.Fatalf("SetJobListView(compact): %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.SetJobListView("tiny"); err != nil {
|
||||||
|
t.Fatalf("SetJobListView(tiny): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config file: %v", err)
|
||||||
|
}
|
||||||
|
var cfg domain.Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshalling config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.JobListView != domain.JobListViewDetailed {
|
||||||
|
t.Errorf("persisted JobListView = %q, want %q", cfg.JobListView, domain.JobListViewDetailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
||||||
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
|
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
|
||||||
svc := newTempService(t, jobs)
|
svc := newTempService(t, jobs)
|
||||||
@@ -596,7 +953,7 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
|||||||
|
|
||||||
var ran int32
|
var ran int32
|
||||||
runStarted := make(chan struct{}, 1)
|
runStarted := make(chan struct{}, 1)
|
||||||
svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
select {
|
select {
|
||||||
case runStarted <- struct{}{}:
|
case runStarted <- struct{}{}:
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeJob trims user-entered fields and applies the same defaults the job
|
||||||
|
// dialog used, so callers do not have to.
|
||||||
|
func normalizeJob(job *domain.Job) {
|
||||||
|
job.Name = strings.TrimSpace(job.Name)
|
||||||
|
job.Folder = strings.TrimSpace(job.Folder)
|
||||||
|
job.Schedule = strings.TrimSpace(job.Schedule)
|
||||||
|
job.Command = strings.TrimSpace(job.Command)
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateJob enforces the minimum executable definition: name, schedule, and
|
||||||
|
// command must be present. Folder is optional. The schedule string itself is not
|
||||||
|
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
||||||
|
// next-run, matching the prior behavior.
|
||||||
|
func validateJob(job domain.Job) error {
|
||||||
|
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
||||||
|
return errors.New("name, schedule, and command are required")
|
||||||
|
}
|
||||||
|
policy := strings.TrimSpace(job.OverlapPolicy)
|
||||||
|
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
||||||
|
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
||||||
|
}
|
||||||
|
if job.TimeoutSeconds != nil && *job.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout must be zero (no timeout) or a positive number of seconds, or unset to inherit the global default")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFileName reports whether a path ends in something that can be a file name.
|
||||||
|
// It is a syntax check only — an existing directory whose name looks like a file
|
||||||
|
// name still passes, and fails at write time — but it catches the shapes a user
|
||||||
|
// types when they mean a folder: a trailing separator, "." and "..".
|
||||||
|
func hasFileName(path string) bool {
|
||||||
|
if strings.HasSuffix(path, "/") || strings.HasSuffix(path, string(filepath.Separator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch filepath.Base(path) {
|
||||||
|
case ".", "..", string(filepath.Separator):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig rejects settings that would break persistence or cleanup.
|
||||||
|
func validateConfig(config domain.Config) error {
|
||||||
|
jobsFile := strings.TrimSpace(config.JobsFile)
|
||||||
|
if jobsFile == "" {
|
||||||
|
return errors.New("jobs file is required")
|
||||||
|
}
|
||||||
|
// A path that names only a folder would be written to as if it were a file
|
||||||
|
// and fail later with an opaque OS error, so require a file name here.
|
||||||
|
if !hasFileName(jobsFile) {
|
||||||
|
return errors.New("jobs file must include a file name")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
return errors.New("logs directory is required")
|
||||||
|
}
|
||||||
|
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
||||||
|
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
|
||||||
|
if config.MaxLogFiles < 0 {
|
||||||
|
return errors.New("max log files must be zero (unlimited) or a positive number")
|
||||||
|
}
|
||||||
|
if config.MaxLogAgeDays < 0 {
|
||||||
|
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
||||||
|
}
|
||||||
|
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
||||||
|
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
||||||
|
}
|
||||||
|
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
||||||
|
return errors.New("overlap policy must be 'skip' or 'queue'")
|
||||||
|
}
|
||||||
|
if config.DefaultTimeoutSeconds < 0 {
|
||||||
|
return errors.New("default timeout must not be negative (0 means no timeout)")
|
||||||
|
}
|
||||||
|
// Empty Theme is accepted and normalized to the branded theme on load, so
|
||||||
|
// older configs (and hand-built ones) stay valid without an explicit theme.
|
||||||
|
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
|
||||||
|
return errors.New("theme must be 'system' or 'gosentry'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
s.mu.Lock()
|
if err != nil {
|
||||||
s.store.Paths.DesktopIcon = iconPath
|
s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
|
||||||
s.mu.Unlock()
|
return
|
||||||
}
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.store.Paths.DesktopIcon = iconPath
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
@@ -112,41 +110,32 @@ type runEnv struct {
|
|||||||
logsDir string
|
logsDir string
|
||||||
maxFiles int
|
maxFiles int
|
||||||
maxAge int
|
maxAge int
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
maxAge: s.store.Config.MaxLogAgeDays,
|
maxAge: s.store.Config.MaxLogAgeDays,
|
||||||
|
timeout: s.effectiveTimeout(job),
|
||||||
}
|
}
|
||||||
// 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
|
||||||
@@ -155,10 +144,9 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
|
|||||||
// is not paused, deferred runs are started one at a time until PendingRuns reaches
|
// is not paused, deferred runs are started one at a time until PendingRuns reaches
|
||||||
// zero. Each deferred run runs on its own goroutine.
|
// zero. Each deferred run runs on its own goroutine.
|
||||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
|
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
|
||||||
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir)
|
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)
|
||||||
@@ -172,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})
|
||||||
@@ -208,6 +197,21 @@ func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
|
|||||||
return s.store.Config.OverlapPolicy
|
return s.store.Config.OverlapPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// effectiveTimeout resolves the run timeout that actually governs a job: the
|
||||||
|
// job's own TimeoutSeconds whenever it is set — including an explicit 0, which
|
||||||
|
// means "no timeout" and deliberately does not inherit — otherwise the global
|
||||||
|
// Config.DefaultTimeoutSeconds. A nil Job.TimeoutSeconds means "inherit the
|
||||||
|
// global default", which is why normalizeJob leaves it nil rather than
|
||||||
|
// backfilling the configured value. A resolved duration of 0 means no timeout;
|
||||||
|
// runner.RunJob treats it as "run without a deadline". The caller must hold mu.
|
||||||
|
func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
|
||||||
|
secs := s.store.Config.DefaultTimeoutSeconds
|
||||||
|
if job.TimeoutSeconds != nil {
|
||||||
|
secs = *job.TimeoutSeconds
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// anyRunningLocked reports whether any loaded job is currently in the "Running"
|
// anyRunningLocked reports whether any loaded job is currently in the "Running"
|
||||||
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
|
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
|
||||||
// must hold mu.
|
// must hold mu.
|
||||||
@@ -249,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
|
||||||
|
|||||||
@@ -103,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) {
|
||||||
@@ -134,7 +143,7 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
@@ -175,7 +184,7 @@ func TestRunDueSequentialSerializes(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
@@ -212,7 +221,7 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -254,11 +263,15 @@ func TestRunDueQueueRerunsAfterFinish(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},
|
||||||
})
|
})
|
||||||
|
// Empty per-job OverlapPolicy inherits the global queue policy.
|
||||||
|
if svc.jobs[0].OverlapPolicy != "" {
|
||||||
|
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -313,7 +326,7 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
|
|||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
if atomic.LoadInt32(&calls) == 0 {
|
if atomic.LoadInt32(&calls) == 0 {
|
||||||
<-release
|
<-release
|
||||||
}
|
}
|
||||||
@@ -354,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.
|
||||||
@@ -365,7 +414,7 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -415,7 +464,7 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -450,51 +499,6 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
|
|
||||||
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
|
|
||||||
// the job queues a re-run.
|
|
||||||
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
|
||||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
|
||||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
|
||||||
})
|
|
||||||
if svc.jobs[0].OverlapPolicy != "" {
|
|
||||||
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
|
|
||||||
}
|
|
||||||
|
|
||||||
entered := make(chan int, 2)
|
|
||||||
release := make(chan struct{})
|
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
|
||||||
entered <- job.ID
|
|
||||||
<-release
|
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
|
||||||
}
|
|
||||||
done := completions(svc)
|
|
||||||
|
|
||||||
primeDue(t, svc, 1)
|
|
||||||
svc.RunDue(time.Now())
|
|
||||||
if id := <-entered; id != 1 {
|
|
||||||
t.Fatalf("started job = %d, want 1", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
primeDue(t, svc, 1)
|
|
||||||
svc.RunDue(time.Now())
|
|
||||||
expectNoEntry(t, entered)
|
|
||||||
|
|
||||||
svc.mu.Lock()
|
|
||||||
pending := svc.runtimes[1].PendingRuns
|
|
||||||
svc.mu.Unlock()
|
|
||||||
if pending != 1 {
|
|
||||||
t.Fatalf("empty per-job policy must inherit global queue, PendingRuns = %d", pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
waitRecord(t, done)
|
|
||||||
if id := <-entered; id != 1 {
|
|
||||||
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
|
|
||||||
}
|
|
||||||
waitRecord(t, done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
||||||
// run is refused while another job is running, and allowed once nothing is.
|
// run is refused while another job is running, and allowed once nothing is.
|
||||||
func TestRunNowSequentialGuard(t *testing.T) {
|
func TestRunNowSequentialGuard(t *testing.T) {
|
||||||
@@ -505,7 +509,7 @@ func TestRunNowSequentialGuard(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
@@ -532,3 +536,95 @@ func TestRunNowSequentialGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
waitRecord(t, done)
|
waitRecord(t, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunDueQueueDrainSkippedWhenPaused verifies that queued overlap runs are not
|
||||||
|
// drained while the scheduler is globally paused, and that pausing clears the
|
||||||
|
// backlog rather than leaving it to fire a stale deferred run on resume.
|
||||||
|
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
||||||
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||||
|
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
entered := make(chan int, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls int32
|
||||||
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
entered <- job.ID
|
||||||
|
<-release
|
||||||
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
|
||||||
|
}
|
||||||
|
done := completions(svc)
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("started job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending := svc.runtimes[1].PendingRuns
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if pending != 1 {
|
||||||
|
t.Fatalf("expected one queued overlap, PendingRuns = %d", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SetGlobalPause(true); err != nil {
|
||||||
|
t.Fatalf("SetGlobalPause: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending = svc.runtimes[1].PendingRuns
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if pending != 0 {
|
||||||
|
t.Errorf("pausing must clear a queued backlog, PendingRuns = %d, want 0", pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
waitRecord(t, done)
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending = svc.runtimes[1].PendingRuns
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if pending != 0 {
|
||||||
|
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 0", pending)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Errorf("runner called %d time(s), want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEffectiveTimeout verifies the three-state resolution: an unset (nil)
|
||||||
|
// Job.TimeoutSeconds falls back to the global default, a positive value
|
||||||
|
// overrides it, and an explicit 0 means "no timeout" without inheriting.
|
||||||
|
func TestEffectiveTimeout(t *testing.T) {
|
||||||
|
svc := newTempService(t, nil)
|
||||||
|
svc.store.Config.DefaultTimeoutSeconds = 30
|
||||||
|
|
||||||
|
inherit := &domain.Job{TimeoutSeconds: nil}
|
||||||
|
if got, want := svc.effectiveTimeout(inherit), 30*time.Second; got != want {
|
||||||
|
t.Errorf("inherited timeout = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
own := &domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(5)}
|
||||||
|
if got, want := svc.effectiveTimeout(own), 5*time.Second; got != want {
|
||||||
|
t.Errorf("per-job timeout = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit per-job 0 must beat a positive global default rather than be
|
||||||
|
// mistaken for "unset".
|
||||||
|
none := &domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(0)}
|
||||||
|
if got, want := svc.effectiveTimeout(none), time.Duration(0); got != want {
|
||||||
|
t.Errorf("explicit per-job zero timeout = %s, want %s (no timeout)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.store.Config.DefaultTimeoutSeconds = 0
|
||||||
|
if got, want := svc.effectiveTimeout(inherit), time.Duration(0); got != want {
|
||||||
|
t.Errorf("inherited timeout with no global default = %s, want %s (no timeout)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,7 +47,7 @@ type Service struct {
|
|||||||
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
||||||
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
||||||
// it is context.Background().
|
// it is context.Background().
|
||||||
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error)
|
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error)
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
||||||
@@ -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,34 +73,74 @@ 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
|
||||||
// store is the Service's sole channel to persistence.
|
// store is the Service's sole channel to persistence.
|
||||||
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
store: store,
|
store: store,
|
||||||
jobs: jobs,
|
runJob: runner.RunJob,
|
||||||
runtimes: domain.NewRuntimes(jobs),
|
ctx: context.Background(),
|
||||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
paused: store.Config.Paused,
|
||||||
runJob: runner.RunJob,
|
|
||||||
ctx: context.Background(),
|
|
||||||
paused: store.Config.Paused,
|
|
||||||
}
|
}
|
||||||
// Parse every schedule once, then compute each job's first next-run so the
|
// No lock is needed here: construction is single-threaded, before Start
|
||||||
// Service is ready to schedule the moment it exists — mirroring the old
|
// launches the timing loop.
|
||||||
// scheduler's reset-on-construction. No lock is needed: construction is
|
s.adoptJobsLocked(jobs)
|
||||||
// single-threaded, before Start launches the timing loop.
|
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
||||||
|
// derived from it: the runtime map, the parsed-schedule cache, and each job's
|
||||||
|
// first next-run — so the Service is ready to schedule the moment it exists,
|
||||||
|
// mirroring the old scheduler's reset-on-construction.
|
||||||
|
//
|
||||||
|
// It backs both construction and a Settings change that points at a different
|
||||||
|
// jobs file. Statistics seeded from existing log files are applied separately by
|
||||||
|
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
|
||||||
|
// must hold mu.
|
||||||
|
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||||
|
s.jobs = jobs
|
||||||
|
s.runtimes = domain.NewRuntimes(jobs)
|
||||||
|
s.schedules = make(map[int]domain.Schedule, len(jobs))
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for index := range s.jobs {
|
for index := range s.jobs {
|
||||||
job := &s.jobs[index]
|
job := &s.jobs[index]
|
||||||
s.parseScheduleLocked(job)
|
s.parseScheduleLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||||
}
|
}
|
||||||
// Seed execution-time statistics from existing log files so the details panel
|
}
|
||||||
// shows accumulated run history immediately after a restart, not just runs
|
|
||||||
// since this process started.
|
// applySeededStatsLocked folds statistics reconstructed from existing log files
|
||||||
for id, seed := range runner.SeedStats(store.Paths.LogsDir, jobs, store.Config.MaxLogFiles) {
|
// 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
|
||||||
@@ -101,8 +151,8 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
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
|
||||||
}
|
}
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins scheduling with the real wall clock. It is the production entry
|
// Start begins scheduling with the real wall clock. It is the production entry
|
||||||
@@ -155,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
|
||||||
|
|||||||
@@ -47,11 +47,3 @@ func TestJobsReturnsCopy(t *testing.T) {
|
|||||||
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStoreReturnsWiredStore(t *testing.T) {
|
|
||||||
store := &storage.Store{}
|
|
||||||
svc := NewService(store, nil)
|
|
||||||
if svc.Store() != store {
|
|
||||||
t.Error("Store() did not return the wired store")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 = "0.11.4"
|
var Version = "1.0.4"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -15,6 +32,39 @@ const (
|
|||||||
ExecutionModeSequential ExecutionMode = "sequential"
|
ExecutionModeSequential ExecutionMode = "sequential"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Theme selects the application's visual appearance. It is a UI-only choice with
|
||||||
|
// no effect on scheduling; it is stored in Config so it persists across launches
|
||||||
|
// alongside the other desktop-shell preferences.
|
||||||
|
type Theme string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ThemeSystem keeps Fyne's built-in theme, following the platform look.
|
||||||
|
ThemeSystem Theme = "system"
|
||||||
|
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
|
||||||
|
// and app icon.
|
||||||
|
ThemeGoSentry Theme = "gosentry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobListView selects how densely the Jobs tab renders its sidebar list. Like
|
||||||
|
// Theme it is a UI-only choice with no effect on scheduling; it lives in Config
|
||||||
|
// so the user's preference survives a restart.
|
||||||
|
type JobListView string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JobListViewDetailed is the three-line row: name, metadata, status.
|
||||||
|
JobListViewDetailed JobListView = "detailed"
|
||||||
|
// JobListViewCompact is the one-line row: name on the left, status on the
|
||||||
|
// right, so many more jobs fit without scrolling.
|
||||||
|
JobListViewCompact JobListView = "compact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsCompact reports whether the compact rendering is selected. Only the exact
|
||||||
|
// "compact" value counts, so empty, legacy, and unrecognised values all read as
|
||||||
|
// detailed — every consumer normalizes them the same way.
|
||||||
|
func (v JobListView) IsCompact() bool {
|
||||||
|
return v == JobListViewCompact
|
||||||
|
}
|
||||||
|
|
||||||
// OverlapPolicy decides what happens when a job's next run fires while the
|
// OverlapPolicy decides what happens when a job's next run fires while the
|
||||||
// previous run is still active.
|
// previous run is still active.
|
||||||
type OverlapPolicy string
|
type OverlapPolicy string
|
||||||
@@ -31,7 +81,15 @@ const (
|
|||||||
// application-level choices: where to read jobs from, where to write logs, and
|
// application-level choices: where to read jobs from, where to write logs, and
|
||||||
// how the desktop shell should behave.
|
// how the desktop shell should behave.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
JobsDir string `json:"jobs_dir"`
|
// JobsFile is the full path of the JSON file holding the job definitions,
|
||||||
|
// file name included, so the user can keep jobs under any name they like. A
|
||||||
|
// relative path is resolved against the program folder.
|
||||||
|
JobsFile string `json:"jobs_file"`
|
||||||
|
// JobsDir is the pre-0.15 setting that named only the directory, with the
|
||||||
|
// file name fixed to jobs.json. It is still read so an older gosentry.json
|
||||||
|
// keeps working: storage.loadOrCreateConfig turns it into JobsFile and
|
||||||
|
// clears it, so the field disappears from the file on the next save.
|
||||||
|
JobsDir string `json:"jobs_dir,omitempty"`
|
||||||
LogsDir string `json:"logs_dir"`
|
LogsDir string `json:"logs_dir"`
|
||||||
MaxLogFiles int `json:"max_log_files"`
|
MaxLogFiles int `json:"max_log_files"`
|
||||||
MaxLogAgeDays int `json:"max_log_age_days"`
|
MaxLogAgeDays int `json:"max_log_age_days"`
|
||||||
@@ -40,7 +98,39 @@ type Config struct {
|
|||||||
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
||||||
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
||||||
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
|
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
|
||||||
Paused bool `json:"paused,omitempty"`
|
// DefaultTimeoutSeconds is the run timeout applied to jobs that leave their
|
||||||
|
// own Job.TimeoutSeconds unset. 0 (the default) means no timeout: such jobs
|
||||||
|
// run to completion however long that takes. It is written even when 0 —
|
||||||
|
// omitempty would hide a deliberate choice from the hand-editable config.
|
||||||
|
DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
|
||||||
|
Paused bool `json:"paused,omitempty"`
|
||||||
|
// Theme selects the visual appearance. Empty is treated as ThemeGoSentry so
|
||||||
|
// configs written before this field existed pick up the branded look.
|
||||||
|
Theme Theme `json:"theme,omitempty"`
|
||||||
|
// JobListView selects the Jobs list density. Empty is treated as
|
||||||
|
// JobListViewDetailed so configs written before this field existed keep the
|
||||||
|
// current three-line rows.
|
||||||
|
JobListView JobListView `json:"job_list_view,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the built-in default settings. It is the config used
|
||||||
|
// when gosentry.json does not yet exist, and is also what the Settings UI
|
||||||
|
// offers to restore via its "Defaults" button.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
JobsFile: "jobs.json",
|
||||||
|
LogsDir: "logs",
|
||||||
|
MaxLogFiles: 100,
|
||||||
|
MaxLogAgeDays: 30,
|
||||||
|
StartOnLogin: false,
|
||||||
|
KeepRunningInTray: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
ExecutionMode: ExecutionModeParallel,
|
||||||
|
OverlapPolicy: OverlapPolicySkip,
|
||||||
|
Theme: ThemeGoSentry,
|
||||||
|
JobListView: JobListViewDetailed,
|
||||||
|
DefaultTimeoutSeconds: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
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
|
||||||
|
// "compact" value selects the one-line rows, so empty and unrecognised values
|
||||||
|
// (including configs written before the field existed) keep the detailed look.
|
||||||
|
func TestJobListViewIsCompact(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
view JobListView
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{JobListViewCompact, true},
|
||||||
|
{JobListViewDetailed, false},
|
||||||
|
{"", false},
|
||||||
|
{"Compact", false},
|
||||||
|
{"tiny", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.view.IsCompact(); got != tc.want {
|
||||||
|
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,28 @@ package domain
|
|||||||
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
|
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
|
||||||
// configuration and never mixes in process-lifetime bookkeeping.
|
// configuration and never mixes in process-lifetime bookkeeping.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Arguments string `json:"arguments,omitempty"`
|
Arguments string `json:"arguments,omitempty"`
|
||||||
StartOnly bool `json:"start_only,omitempty"`
|
StartOnly bool `json:"start_only,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
OverlapPolicy string `json:"overlap_policy,omitempty"`
|
OverlapPolicy string `json:"overlap_policy,omitempty"`
|
||||||
|
// TimeoutSeconds bounds how long a run may take before it is killed. It is a
|
||||||
|
// pointer so the three states stay distinguishable on disk: absent (nil)
|
||||||
|
// means "inherit the global Config.DefaultTimeoutSeconds", mirroring
|
||||||
|
// OverlapPolicy's empty string; an explicit 0 means "no timeout" and does
|
||||||
|
// not inherit; a positive value is the per-job limit in seconds. The
|
||||||
|
// inherited global default may itself be 0, also meaning no timeout.
|
||||||
|
// normalizeJobs must leave nil untouched rather than backfilling a value.
|
||||||
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeoutSecondsPtr returns a pointer suitable for Job.TimeoutSeconds. It exists
|
||||||
|
// because nil (inherit) and an explicit 0 (no timeout) are different states, so
|
||||||
|
// callers cannot just assign an int.
|
||||||
|
func TimeoutSecondsPtr(seconds int) *int {
|
||||||
|
return &seconds
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +74,12 @@ 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)
|
||||||
return false, "Autostart shortcut does not start in tray"
|
if strings.TrimSpace(arguments) != expectedArgs {
|
||||||
|
if startInTray {
|
||||||
|
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)`
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
|
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
|
||||||
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
|
|
||||||
t.Fatal("expected paths to match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
|
|
||||||
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
|
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
|
||||||
t.Fatal("expected paths with spaces to match")
|
t.Fatal("expected paths to match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,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")
|
||||||
@@ -115,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstallDesktopIntegrationWritesDesktopAndIcon(t *testing.T) {
|
||||||
|
dataHome := t.TempDir()
|
||||||
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||||
|
|
||||||
|
appID := "ru.mixeme.gosentry.desktop"
|
||||||
|
executable := filepath.Join(dataHome, "bin", "gosentry")
|
||||||
|
icon := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix is enough for file presence
|
||||||
|
|
||||||
|
iconPath, err := InstallDesktopIntegration(appID, executable, icon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstallDesktopIntegration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(iconPath); err != nil {
|
||||||
|
t.Fatalf("icon file: %v", err)
|
||||||
|
}
|
||||||
|
iconData, err := os.ReadFile(iconPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read icon: %v", err)
|
||||||
|
}
|
||||||
|
if string(iconData) != string(icon) {
|
||||||
|
t.Fatalf("icon bytes mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopPath := filepath.Join(dataHome, "applications", appID+".desktop")
|
||||||
|
data, err := os.ReadFile(desktopPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read desktop entry: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, "Name=GoSentry") {
|
||||||
|
t.Fatalf("desktop entry missing Name: %s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "StartupWMClass="+appID) {
|
||||||
|
t.Fatalf("desktop entry missing WM class: %s", text)
|
||||||
|
}
|
||||||
|
wantExec := "Exec=" + quoteDesktopExec(executable)
|
||||||
|
if !strings.Contains(text, wantExec) {
|
||||||
|
t.Fatalf("desktop entry exec = %s, want substring %q", text, wantExec)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "Icon="+iconPath) {
|
||||||
|
t.Fatalf("desktop entry missing Icon path: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteDesktopExecQuotesPath(t *testing.T) {
|
||||||
|
got := quoteDesktopExec("/opt/Go Sentry/gosentry")
|
||||||
|
if got != `"/opt/Go Sentry/gosentry"` {
|
||||||
|
t.Errorf("quoteDesktopExec = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package filemanager opens a directory in the desktop file manager, so the
|
||||||
|
// UI can reveal a configured folder (logs, jobs) without knowing which handler
|
||||||
|
// the platform uses.
|
||||||
|
package filemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open shows dir in the platform file manager. A missing path, a path that is
|
||||||
|
// not a directory, and a handler that fails to start are all returned as
|
||||||
|
// errors so the caller can surface them instead of appearing to do nothing.
|
||||||
|
func Open(dir string) error {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("folder does not exist: %s", dir)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("not a folder: %s", dir)
|
||||||
|
}
|
||||||
|
name, args := openCommand(dir)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("opening a folder is not supported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
command := exec.Command(name, args...)
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The handler hands the request to the desktop shell and exits on its own —
|
||||||
|
// Windows Explorer even exits non-zero after opening the window — so its
|
||||||
|
// status carries no information. Wait runs only to release the process
|
||||||
|
// handle, and never blocks the caller.
|
||||||
|
go func() { _ = command.Wait() }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package filemanager
|
||||||
|
|
||||||
|
// openCommand returns the XDG invocation for dir. xdg-open picks whichever
|
||||||
|
// file manager the desktop environment has registered for directories.
|
||||||
|
func openCommand(dir string) (string, []string) {
|
||||||
|
return "xdg-open", []string{dir}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows && !linux
|
||||||
|
|
||||||
|
package filemanager
|
||||||
|
|
||||||
|
// openCommand has no handler to name on platforms GoSentry does not ship for.
|
||||||
|
// An empty name makes Open report that the action is unavailable instead of
|
||||||
|
// running something arbitrary.
|
||||||
|
func openCommand(dir string) (string, []string) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package filemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The success path is deliberately not tested: it would pop a real file
|
||||||
|
// manager window on the machine running the suite. Only the guards that keep
|
||||||
|
// Open from launching anything are exercised here.
|
||||||
|
|
||||||
|
func TestOpenRejectsMissingFolder(t *testing.T) {
|
||||||
|
missing := filepath.Join(t.TempDir(), "no-such-folder")
|
||||||
|
|
||||||
|
err := Open(missing)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Open on a missing folder returned nil, want an error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), missing) {
|
||||||
|
t.Errorf("error %q does not name the missing folder %q", err, missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRejectsFile(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "gosentry.log")
|
||||||
|
if err := os.WriteFile(file, []byte("log"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Open(file)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Open on a file returned nil, want an error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a folder") {
|
||||||
|
t.Errorf("error %q does not report that the path is not a folder", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenCommandNamesPlatformHandler checks the supported platforms name a
|
||||||
|
// handler (an empty name makes Open report the action as unavailable) and that
|
||||||
|
// the directory is passed as a single argument, so spaces need no quoting.
|
||||||
|
func TestOpenCommandNamesPlatformHandler(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "log files")
|
||||||
|
|
||||||
|
name, args := openCommand(dir)
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if name != "explorer" {
|
||||||
|
t.Errorf("handler on windows = %q, want %q", name, "explorer")
|
||||||
|
}
|
||||||
|
case "linux":
|
||||||
|
if name != "xdg-open" {
|
||||||
|
t.Errorf("handler on linux = %q, want %q", name, "xdg-open")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if name != "" {
|
||||||
|
t.Errorf("handler on %s = %q, want no handler", runtime.GOOS, name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) != 1 || args[0] != filepath.Clean(dir) {
|
||||||
|
t.Errorf("arguments = %q, want the single path %q", args, filepath.Clean(dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package filemanager
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
|
// openCommand returns the Explorer invocation for dir. The path is cleaned
|
||||||
|
// because Explorer ignores an argument that mixes separators, and it is passed
|
||||||
|
// as a single argument so spaces need no quoting.
|
||||||
|
func openCommand(dir string) (string, []string) {
|
||||||
|
return "explorer", []string{filepath.Clean(dir)}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -50,22 +50,6 @@ func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupLogsKeepsFilesWithinAgeLimit(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
for i := 1; i <= 3; i++ {
|
|
||||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
|
|
||||||
setModTime(t, path, time.Duration(i)*24*time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
entries, _ := os.ReadDir(dir)
|
|
||||||
if len(entries) != 3 {
|
|
||||||
t.Errorf("expected 3 files kept within age limit, got %d", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
|
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
|
||||||
// than maxFiles log files exist the oldest (by modification time) are removed.
|
// than maxFiles log files exist the oldest (by modification time) are removed.
|
||||||
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
pathEnd := index + len(extension)
|
|
||||||
programPath := trimmed[:pathEnd]
|
|
||||||
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
|
||||||
}
|
}
|
||||||
return command
|
if pathEnd < 0 {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
programPath := trimmed[:pathEnd]
|
||||||
|
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// earliestBoundedExtensionEnd returns the offset just past the first
|
||||||
|
// occurrence of extension in s that ends at a token boundary (end of string
|
||||||
|
// or whitespace), or -1 if none does. Scanning left to right and rejecting
|
||||||
|
// unbounded matches keeps a trailing "...\App.exe" inside an argument, such
|
||||||
|
// as "run.bat C:\tool.exe", from being mistaken for the program path.
|
||||||
|
func earliestBoundedExtensionEnd(s, extension string) int {
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
index := strings.Index(s[offset:], extension)
|
||||||
|
if index < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
end := offset + index + len(extension)
|
||||||
|
if end == len(s) {
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeRuneInString(s[end:])
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
offset += index + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startsWithWindowsRootedPath(command string) bool {
|
func startsWithWindowsRootedPath(command string) bool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,16 +12,23 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandTimeout = 30 * time.Second
|
|
||||||
const commandWaitDelay = 2 * time.Second
|
const commandWaitDelay = 2 * time.Second
|
||||||
|
|
||||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) {
|
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
// Commands can hang forever if a script waits for input or a child process
|
// Commands can hang forever if a script waits for input or a child process
|
||||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
// stalls. The effective timeout is resolved by the caller (per-job value or
|
||||||
// scheduler; later it can become a per-job setting without changing the
|
// the global default), keeping the runner ignorant of the global config. A
|
||||||
// runner contract.
|
// non-positive timeout means "no timeout": context.WithTimeout(ctx, 0) would
|
||||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
// expire immediately, so fall back to a plain cancelable context that only
|
||||||
|
// ever ends via ctx (e.g. app shutdown).
|
||||||
|
var runCtx context.Context
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if timeout > 0 {
|
||||||
|
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
||||||
|
} else {
|
||||||
|
runCtx, cancel = context.WithCancel(ctx)
|
||||||
|
}
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var output string
|
var output string
|
||||||
@@ -29,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)
|
||||||
@@ -49,7 +64,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
duration := time.Since(started).Round(time.Millisecond)
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
durationMS = duration.Milliseconds()
|
durationMS = duration.Milliseconds()
|
||||||
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
||||||
state, detail = runStateDetail(err, runCtx.Err(), duration)
|
state, detail = runStateDetail(err, runCtx.Err(), duration, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -101,17 +116,17 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStateDetail(err error, runErr error, duration time.Duration) (string, string) {
|
func runStateDetail(err error, runErr error, duration time.Duration, timeout time.Duration) (string, string) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||||
}
|
}
|
||||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||||
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
return "Failed", fmt.Sprintf("Timed out after %s", timeout)
|
||||||
}
|
}
|
||||||
if errors.Is(err, exec.ErrWaitDelay) {
|
if errors.Is(err, exec.ErrWaitDelay) {
|
||||||
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
|
|||||||
Command: echoCommand("header test output"),
|
Command: echoCommand("header test output"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Schedule", logsDir)
|
record, err := RunJob(context.Background(), &job, "Schedule", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestRunJobRecordFields(t *testing.T) {
|
|||||||
Command: echoCommand("record field check"),
|
Command: echoCommand("record field check"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir(), 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func TestRunJobWritesLogFile(t *testing.T) {
|
|||||||
Command: echoCommand("hello from test"),
|
Command: echoCommand("hello from test"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
|||||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
|||||||
Command: scriptPath,
|
Command: scriptPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
|||||||
Arguments: "/C\necho separate arguments ok",
|
Arguments: "/C\necho separate arguments ok",
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
|
|||||||
job.Arguments = "/C\nexit /b 1"
|
job.Arguments = "/C\nexit /b 1"
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
StartOnly: true,
|
StartOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
|||||||
StartOnly: true,
|
StartOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -348,3 +348,136 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunJobTimesOut(t *testing.T) {
|
||||||
|
command := "sh"
|
||||||
|
arguments := "-c\nsleep 5"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
|
// timeout waits ~5s; ping to localhost is a portable stall on hosts where
|
||||||
|
// timeout refuses to run without an interactive console.
|
||||||
|
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 50,
|
||||||
|
Name: "Timeout Test",
|
||||||
|
Command: command,
|
||||||
|
Arguments: arguments,
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 100*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.State != "Failed" {
|
||||||
|
t.Fatalf("expected timed-out job to fail, got state %q detail %q", record.State, record.Detail)
|
||||||
|
}
|
||||||
|
if !strings.Contains(record.Detail, "Timed out after 100ms") {
|
||||||
|
t.Fatalf("expected timeout detail with the effective timeout, got %q", record.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobZeroTimeoutMeansNoTimeout(t *testing.T) {
|
||||||
|
command := "sh"
|
||||||
|
arguments := "-c\nsleep 0.2"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
|
arguments = "/C\nping -n 2 127.0.0.1 >NUL"
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 52,
|
||||||
|
Name: "No Timeout Test",
|
||||||
|
Command: command,
|
||||||
|
Arguments: arguments,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-positive timeout must not expire immediately (context.WithTimeout
|
||||||
|
// with a zero duration would); the job must run to completion.
|
||||||
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.State != "OK" {
|
||||||
|
t.Fatalf("expected job with no timeout to complete OK, got state %q detail %q", record.State, record.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A StartOnly run must not leave a watcher goroutine behind. exec.CommandContext
|
||||||
|
// keeps one alive until Wait returns or the context is done, and StartOnly never
|
||||||
|
// waits, so binding it to the caller's cancelable context would leak one
|
||||||
|
// goroutine per run for the lifetime of the app — and then, on shutdown, kill a
|
||||||
|
// process whose handle startJobOnly has already released.
|
||||||
|
func TestRunJobStartOnlyLeavesNoContextWatcher(t *testing.T) {
|
||||||
|
command := "sh"
|
||||||
|
arguments := "-c\nexit 0"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
|
arguments = "/C\nexit /b 0"
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 53,
|
||||||
|
Name: "Start Only Goroutines",
|
||||||
|
Command: command,
|
||||||
|
Arguments: arguments,
|
||||||
|
StartOnly: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const runs = 5
|
||||||
|
before := settledGoroutines()
|
||||||
|
for i := 0; i < runs; i++ {
|
||||||
|
if _, err := RunJob(ctx, &job, "Manual", t.TempDir(), 30*time.Second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Counted before cancel on purpose: a watcher would still be parked on
|
||||||
|
// ctx.Done() at this point, and cancelling first would release it.
|
||||||
|
if leaked := settledGoroutines() - before; leaked > 1 {
|
||||||
|
t.Errorf("%d goroutines left after %d StartOnly runs, want none tied to the run context", leaked, runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settledGoroutines returns the goroutine count once it has stopped falling, so
|
||||||
|
// a goroutine that is still on its way out is not mistaken for a leak.
|
||||||
|
func settledGoroutines() int {
|
||||||
|
lowest := runtime.NumGoroutine()
|
||||||
|
for stable, i := 0, 0; stable < 3 && i < 100; i++ {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if count := runtime.NumGoroutine(); count < lowest {
|
||||||
|
lowest, stable = count, 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stable++
|
||||||
|
}
|
||||||
|
return lowest
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
|
||||||
|
command := "sh"
|
||||||
|
arguments := "-c\nsleep 5"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
|
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 51,
|
||||||
|
Name: "Start Only Timeout",
|
||||||
|
Command: command,
|
||||||
|
Arguments: arguments,
|
||||||
|
StartOnly: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiny run timeout must not affect StartOnly jobs: they never wait on the
|
||||||
|
// timed run context, so the launch succeeds regardless.
|
||||||
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.State != "OK" {
|
||||||
|
t.Fatalf("expected start-only job to be OK despite tiny timeout, got state %q detail %q", record.State, record.Detail)
|
||||||
|
}
|
||||||
|
if !strings.Contains(record.Detail, "not waiting for process exit") {
|
||||||
|
t.Fatalf("expected start-only detail, got %q", record.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
|
|||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
}
|
}
|
||||||
if durationMS >= 0 {
|
if durationMS >= 0 {
|
||||||
content.WriteString("state: " + state + "\nduration: " + itoa(durationMS) + "\n\n")
|
content.WriteString("state: " + state + "\nduration: " + strconv.FormatInt(durationMS, 10) + "\n\n")
|
||||||
} else {
|
} else {
|
||||||
content.WriteString("state: " + state + "\n\n")
|
content.WriteString("state: " + state + "\n\n")
|
||||||
}
|
}
|
||||||
@@ -31,25 +31,6 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int64) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 20)
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedStatsBasic(t *testing.T) {
|
func TestSeedStatsBasic(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
job := domain.Job{ID: 1, Name: "Build"}
|
job := domain.Job{ID: 1, Name: "Build"}
|
||||||
@@ -80,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
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ const (
|
|||||||
// The config file stays beside the executable so the portable build behaves
|
// The config file stays beside the executable so the portable build behaves
|
||||||
// predictably: moving the program folder moves its settings with it.
|
// predictably: moving the program folder moves its settings with it.
|
||||||
ConfigFileName = "gosentry.json"
|
ConfigFileName = "gosentry.json"
|
||||||
// Jobs are kept in a separate JSON file because the user can choose a
|
// Jobs are kept in a separate JSON file because the user can point the
|
||||||
// different jobs directory, while application settings remain local to the
|
// configuration at any jobs file they like, while application settings
|
||||||
// installed/copied program.
|
// remain local to the installed/copied program. This is only the default
|
||||||
|
// name, used before the config is read and when an older config that named
|
||||||
|
// just a directory is migrated.
|
||||||
JobsFileName = "jobs.json"
|
JobsFileName = "jobs.json"
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -23,10 +25,13 @@ type Paths struct {
|
|||||||
ExecutablePath string
|
ExecutablePath string
|
||||||
AppDir string
|
AppDir string
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
JobsDir string
|
// JobsDir is the directory containing JobsPath. It is derived from the
|
||||||
JobsPath string
|
// configured jobs file, never configured on its own, and exists so writers
|
||||||
LogsDir string
|
// can create the folder before saving.
|
||||||
DesktopIcon string
|
JobsDir string
|
||||||
|
JobsPath string
|
||||||
|
LogsDir string
|
||||||
|
DesktopIcon string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolvePaths() (Paths, error) {
|
func ResolvePaths() (Paths, error) {
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
@@ -16,6 +13,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,143 +69,79 @@ 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
|
||||||
return err
|
path := s.Paths.ConfigPath
|
||||||
|
config := s.Config
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(path, config)
|
||||||
}
|
}
|
||||||
return writeJSON(s.Paths.ConfigPath, s.Config)
|
}
|
||||||
|
|
||||||
|
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
|
||||||
|
// copied, so the caller may keep mutating its own slice as soon as this returns.
|
||||||
|
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
|
||||||
|
dir := s.Paths.JobsDir
|
||||||
|
path := s.Paths.JobsPath
|
||||||
|
snapshot := make([]domain.Job, len(jobs))
|
||||||
|
copy(snapshot, jobs)
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
return s.PrepareSaveConfig()()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
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) {
|
// ResolveConfiguredPath turns a file or directory path from the config into the
|
||||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
// absolute path the application will actually use. It is exported so callers
|
||||||
// executable, while logs are grouped under a dedicated subdirectory.
|
// outside storage — the settings tab, which opens the configured logs folder —
|
||||||
config := domain.Config{
|
// apply the same rule to a path the user has typed but not yet saved.
|
||||||
JobsDir: ".",
|
func ResolveConfiguredPath(appDir string, path string) string {
|
||||||
LogsDir: "logs",
|
if filepath.IsAbs(path) {
|
||||||
MaxLogFiles: 100,
|
// Cleaned so two spellings of the same file (forward vs. backslashes, a
|
||||||
MaxLogAgeDays: 30,
|
// trailing separator) resolve to the same string. UpdateSettings compares
|
||||||
StartOnLogin: false,
|
// this against Paths.JobsPath to decide whether the jobs file is changing,
|
||||||
KeepRunningInTray: true,
|
// so an uncleaned path here could trigger a spurious adoption against the
|
||||||
NotifyOnFailure: true,
|
// file the app is already using.
|
||||||
ExecutionMode: domain.ExecutionModeParallel,
|
return filepath.Clean(path)
|
||||||
OverlapPolicy: domain.OverlapPolicySkip,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
return config, writeJSON(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(paths.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(config.JobsDir) == "" {
|
|
||||||
// Empty paths are treated as missing values rather than intentional root
|
|
||||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
|
||||||
config.JobsDir = "."
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
|
||||||
config.LogsDir = "logs"
|
|
||||||
}
|
|
||||||
if config.MaxLogFiles <= 0 {
|
|
||||||
config.MaxLogFiles = 100
|
|
||||||
}
|
|
||||||
if config.MaxLogAgeDays <= 0 {
|
|
||||||
config.MaxLogAgeDays = 30
|
|
||||||
}
|
|
||||||
if config.ExecutionMode == "" {
|
|
||||||
config.ExecutionMode = domain.ExecutionModeParallel
|
|
||||||
}
|
|
||||||
if config.OverlapPolicy == "" {
|
|
||||||
config.OverlapPolicy = domain.OverlapPolicySkip
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
||||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
|
||||||
// and manual execution without inventing a command.
|
|
||||||
jobs := defaultJobs()
|
|
||||||
normalizeJobs(jobs)
|
|
||||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var file domain.JobsFile
|
|
||||||
if err := json.Unmarshal(data, &file); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file.Jobs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeJobs(jobs []domain.Job) {
|
|
||||||
next := 1
|
|
||||||
for index := range jobs {
|
|
||||||
job := &jobs[index]
|
|
||||||
if job.ID <= 0 {
|
|
||||||
// IDs are assigned only when absent. Existing IDs stay stable because
|
|
||||||
// History and future log associations use them to identify jobs.
|
|
||||||
job.ID = next
|
|
||||||
}
|
|
||||||
if job.ID >= next {
|
|
||||||
next = job.ID + 1
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Name) == "" {
|
|
||||||
job.Name = "Untitled job"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Schedule) == "" {
|
|
||||||
job.Schedule = "@every 1m"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Command) == "" {
|
|
||||||
// An empty command would fail in a confusing way. A safe echo command
|
|
||||||
// gives the user something observable and harmless instead.
|
|
||||||
job.Command = echoCommand("GoSentry job ran")
|
|
||||||
}
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
// Runtime state (last run, next run, status, output, activity) is no longer
|
|
||||||
// part of Job. It is reconstructed each time the app starts via
|
|
||||||
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
|
||||||
return resolveConfiguredDir(appDir, jobsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveConfiguredDir(appDir string, dir string) string {
|
|
||||||
if filepath.IsAbs(dir) {
|
|
||||||
return dir
|
|
||||||
}
|
}
|
||||||
// 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,
|
||||||
// Explorer launches, and terminal launches consistent.
|
// Explorer launches, and terminal launches consistent.
|
||||||
return filepath.Clean(filepath.Join(appDir, dir))
|
return filepath.Clean(filepath.Join(appDir, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) applyConfigPaths() {
|
func (s *Store) applyConfigPaths() {
|
||||||
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
// The jobs file is configured as a whole path; its directory is derived so
|
||||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
// SaveJobs can create the folder when the user points at a new location.
|
||||||
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
s.Paths.JobsPath = ResolveConfiguredPath(s.Paths.AppDir, s.Config.JobsFile)
|
||||||
|
s.Paths.JobsDir = filepath.Dir(s.Paths.JobsPath)
|
||||||
|
s.Paths.LogsDir = ResolveConfiguredPath(s.Paths.AppDir, s.Config.LogsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, "", " ")
|
||||||
@@ -196,45 +151,45 @@ 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultJobs() []domain.Job {
|
// writeFileAtomic writes data to a temp file in dir, syncs it, then renames it
|
||||||
return []domain.Job{
|
// 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
|
||||||
ID: 1,
|
// path holding a truncated or empty file the way a direct os.WriteFile could.
|
||||||
Name: "Hello scheduler",
|
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
||||||
Folder: "Examples",
|
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||||
Schedule: "@every 1m",
|
if err != nil {
|
||||||
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
return err
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Write timestamp",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "*/1 * * * *",
|
|
||||||
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Name: "Paused sample",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
func echoCommand(message string) string {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
if runtime.GOOS == "windows" {
|
tmp.Close()
|
||||||
return "echo " + message
|
return err
|
||||||
}
|
}
|
||||||
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
if err := tmp.Sync(); err != nil {
|
||||||
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
tmp.Close()
|
||||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
|
config := domain.DefaultConfig()
|
||||||
|
|
||||||
|
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return config, writeJSON(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||||
|
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||||
|
// The fallbacks restore a value in either case.
|
||||||
|
config.JobsFile = ""
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config written before the setting named a file carries jobs_dir instead
|
||||||
|
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||||
|
// used, then drop the old key so the file is rewritten in the current shape.
|
||||||
|
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||||
|
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||||
|
}
|
||||||
|
config.JobsDir = ""
|
||||||
|
if strings.TrimSpace(config.JobsFile) == "" {
|
||||||
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
|
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||||
|
config.JobsFile = JobsFileName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
config.LogsDir = "logs"
|
||||||
|
}
|
||||||
|
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||||
|
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||||
|
// backfilling it here would make that choice impossible to persist. A config
|
||||||
|
// written before either field existed already carries 0 from json.Unmarshal
|
||||||
|
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||||
|
// pick up 100 / 30 without an explicit backfill.
|
||||||
|
if config.ExecutionMode == "" {
|
||||||
|
config.ExecutionMode = domain.ExecutionModeParallel
|
||||||
|
}
|
||||||
|
if config.OverlapPolicy == "" {
|
||||||
|
config.OverlapPolicy = domain.OverlapPolicySkip
|
||||||
|
}
|
||||||
|
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
|
||||||
|
// value ("no timeout"), not a missing one, so backfilling it here would make
|
||||||
|
// the setting impossible to persist. Negative values are rejected by
|
||||||
|
// app.validateConfig before they can be saved.
|
||||||
|
if config.Theme == "" {
|
||||||
|
config.Theme = domain.ThemeGoSentry
|
||||||
|
}
|
||||||
|
if config.Theme == "default" {
|
||||||
|
config.Theme = domain.ThemeSystem
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||||
|
// reports whether the file was there: a missing file is not an error but the
|
||||||
|
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||||
|
// needs when the user points the application at a different jobs file.
|
||||||
|
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
normalizeJobs(file.Jobs)
|
||||||
|
return file.Jobs, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||||
|
jobs, found, err := LoadJobsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||||
|
// execution without inventing a command. The failure sample stays disabled
|
||||||
|
// so it does not spam notifications; Run now still works for testing.
|
||||||
|
jobs = defaultJobs()
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
|
next := 1
|
||||||
|
seen := make(map[int]bool, len(jobs))
|
||||||
|
for index := range jobs {
|
||||||
|
job := &jobs[index]
|
||||||
|
if job.ID <= 0 || seen[job.ID] {
|
||||||
|
// IDs are assigned only when absent or already claimed by an earlier job
|
||||||
|
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||||
|
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||||
|
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||||
|
// because History and future log associations use them to identify jobs.
|
||||||
|
job.ID = next
|
||||||
|
}
|
||||||
|
seen[job.ID] = true
|
||||||
|
if job.ID >= next {
|
||||||
|
next = job.ID + 1
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Name) == "" {
|
||||||
|
job.Name = "Untitled job"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Schedule) == "" {
|
||||||
|
job.Schedule = "@every 1m"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Command) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
|
job.Command = echoCommand("GoSentry job ran")
|
||||||
|
}
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||||
|
// part of Job. It is reconstructed each time the app starts via
|
||||||
|
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultJobs() []domain.Job {
|
||||||
|
return []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Hello scheduler",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Write timestamp",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "*/1 * * * *",
|
||||||
|
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Paused sample",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
|
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 {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "echo " + message
|
||||||
|
}
|
||||||
|
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
||||||
|
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
||||||
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ func TestConfigRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := domain.Config{
|
want := domain.Config{
|
||||||
JobsDir: "/custom/jobs",
|
JobsFile: "/custom/jobs/team.json",
|
||||||
LogsDir: "/custom/logs",
|
LogsDir: "/custom/logs",
|
||||||
MaxLogFiles: 50,
|
MaxLogFiles: 50,
|
||||||
MaxLogAgeDays: 14,
|
MaxLogAgeDays: 14,
|
||||||
@@ -94,8 +95,8 @@ func TestConfigRoundTrip(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.JobsDir != want.JobsDir {
|
if got.JobsFile != want.JobsFile {
|
||||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
t.Errorf("JobsFile: got %q, want %q", got.JobsFile, want.JobsFile)
|
||||||
}
|
}
|
||||||
if got.LogsDir != want.LogsDir {
|
if got.LogsDir != want.LogsDir {
|
||||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||||
@@ -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{
|
||||||
@@ -156,8 +201,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.JobsDir != "." {
|
if got.JobsFile != "jobs.json" {
|
||||||
t.Errorf("default JobsDir = %q, want '.'", got.JobsDir)
|
t.Errorf("default JobsFile = %q, want 'jobs.json'", got.JobsFile)
|
||||||
}
|
}
|
||||||
if got.LogsDir != "logs" {
|
if got.LogsDir != "logs" {
|
||||||
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
|
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
|
||||||
@@ -168,12 +213,272 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
if got.MaxLogAgeDays != 30 {
|
if got.MaxLogAgeDays != 30 {
|
||||||
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
|
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
|
||||||
}
|
}
|
||||||
|
if got.DefaultTimeoutSeconds != 0 {
|
||||||
|
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
|
||||||
|
}
|
||||||
|
if got.Theme != domain.ThemeGoSentry {
|
||||||
|
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeGoSentry)
|
||||||
|
}
|
||||||
|
if got.JobListView != domain.JobListViewDetailed {
|
||||||
|
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
|
||||||
|
}
|
||||||
// The function must have written the defaults to gosentry.json.
|
// The function must have written the defaults to gosentry.json.
|
||||||
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
||||||
t.Errorf("gosentry.json should have been created: %v", err)
|
t.Errorf("gosentry.json should have been created: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigPreservesZeroRetentionLimits verifies that 0 in
|
||||||
|
// max_log_files / max_log_age_days is read back as 0 ("keep everything"), not
|
||||||
|
// backfilled to the 100 / 30 defaults, since a config that already has the
|
||||||
|
// field set is not the "field is missing" case loadOrCreateConfig backfills.
|
||||||
|
func TestLoadOrCreateConfigPreservesZeroRetentionLimits(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
want := domain.DefaultConfig()
|
||||||
|
want.MaxLogFiles = 0
|
||||||
|
want.MaxLogAgeDays = 0
|
||||||
|
if err := writeJSON(paths.ConfigPath, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.MaxLogFiles != 0 {
|
||||||
|
t.Errorf("MaxLogFiles: got %d, want 0 (unlimited)", got.MaxLogFiles)
|
||||||
|
}
|
||||||
|
if got.MaxLogAgeDays != 0 {
|
||||||
|
t.Errorf("MaxLogAgeDays: got %d, want 0 (unlimited)", got.MaxLogAgeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
|
||||||
|
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
||||||
|
// sees scheduled and manual execution without inventing a command.
|
||||||
|
func TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "jobs.json")
|
||||||
|
|
||||||
|
got, err := loadOrCreateJobs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := defaultJobs()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d jobs, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i].Name != want[i].Name || got[i].Schedule != want[i].Schedule || got[i].Command != want[i].Command || got[i].Enabled != want[i].Enabled {
|
||||||
|
t.Errorf("job %d = %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The function must have written the seeded jobs to jobs.json.
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jobs.json should have been created: %v", err)
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(file.Jobs) != len(want) {
|
||||||
|
t.Errorf("jobs.json has %d jobs, want %d", len(file.Jobs), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigMigratesLegacyThemeDefault covers a gosentry.json that
|
||||||
|
// still stores the retired "default" theme value: load normalizes it to system.
|
||||||
|
func TestLoadOrCreateConfigMigratesLegacyThemeDefault(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
legacy := map[string]any{
|
||||||
|
"jobs_file": "jobs.json",
|
||||||
|
"logs_dir": "logs",
|
||||||
|
"max_log_files": 100,
|
||||||
|
"max_log_age_days": 30,
|
||||||
|
"theme": "default",
|
||||||
|
}
|
||||||
|
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Theme != domain.ThemeSystem {
|
||||||
|
t.Errorf("migrated Theme = %q, want %q", got.Theme, domain.ThemeSystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigKeepsZeroTimeoutOnReload guards the "0 = no timeout"
|
||||||
|
// setting against being normalized away when an existing gosentry.json is read
|
||||||
|
// back. Loading must not treat 0 as a missing value.
|
||||||
|
func TestLoadOrCreateConfigKeepsZeroTimeoutOnReload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call writes the defaults (DefaultTimeoutSeconds = 0) to disk.
|
||||||
|
if _, err := loadOrCreateConfig(paths); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Second call takes the "file exists" branch, where normalization runs.
|
||||||
|
reloaded, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reloaded.DefaultTimeoutSeconds != 0 {
|
||||||
|
t.Errorf("reloaded DefaultTimeoutSeconds = %d, want 0 (no timeout)", reloaded.DefaultTimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigMigratesJobsDir covers a gosentry.json written before
|
||||||
|
// the setting named a file: the old jobs_dir keeps pointing at the same jobs
|
||||||
|
// file, and the retired key is dropped so it is not written back.
|
||||||
|
func TestLoadOrCreateConfigMigratesJobsDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
legacy := map[string]any{
|
||||||
|
"jobs_dir": filepath.Join(dir, "shared"),
|
||||||
|
"logs_dir": "logs",
|
||||||
|
"max_log_files": 100,
|
||||||
|
"max_log_age_days": 30,
|
||||||
|
}
|
||||||
|
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "shared", JobsFileName)
|
||||||
|
if got.JobsFile != want {
|
||||||
|
t.Errorf("migrated JobsFile: got %q, want %q", got.JobsFile, want)
|
||||||
|
}
|
||||||
|
if got.JobsDir != "" {
|
||||||
|
t.Errorf("legacy JobsDir should be cleared, got %q", got.JobsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migrated config must not carry the retired key once it is saved.
|
||||||
|
store := &Store{Paths: paths, Config: got}
|
||||||
|
if err := store.SaveConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "jobs_dir") {
|
||||||
|
t.Errorf("saved config should not contain jobs_dir:\n%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadJobsFileReportsMissingWithoutCreating covers the loader the Settings
|
||||||
|
// tab uses to decide between adopting a jobs file and writing the current jobs
|
||||||
|
// to it: a missing file is reported as "not found" rather than an error, and —
|
||||||
|
// unlike the startup path — is not seeded with sample jobs.
|
||||||
|
func TestLoadJobsFileReportsMissingWithoutCreating(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
missing := filepath.Join(dir, "nothing-here.json")
|
||||||
|
|
||||||
|
jobs, found, err := LoadJobsFile(missing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing file should not be an error: %v", err)
|
||||||
|
}
|
||||||
|
if found || jobs != nil {
|
||||||
|
t.Errorf("missing file: got found=%v jobs=%+v, want false/nil", found, jobs)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(missing); !os.IsNotExist(err) {
|
||||||
|
t.Error("LoadJobsFile must not create the file it was asked about")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing file comes back normalized, so a hand-written jobs file gains
|
||||||
|
// its IDs and defaults before the application adopts it.
|
||||||
|
path := filepath.Join(dir, "hand-written.json")
|
||||||
|
if err := writeJSON(path, domain.JobsFile{Jobs: []domain.Job{{Name: "No ID"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jobs, found, err = LoadJobsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found || len(jobs) != 1 {
|
||||||
|
t.Fatalf("existing file: got found=%v jobs=%+v, want true and one job", found, jobs)
|
||||||
|
}
|
||||||
|
if jobs[0].ID != 1 || jobs[0].Schedule == "" || jobs[0].Command == "" {
|
||||||
|
t.Errorf("loaded job should be normalized, got %+v", jobs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyConfigPathsDerivesJobsDir checks that the jobs file drives both
|
||||||
|
// resolved paths: relative values resolve against the program folder, and the
|
||||||
|
// containing directory comes from the file name the user chose.
|
||||||
|
func TestApplyConfigPathsDerivesJobsDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := &Store{
|
||||||
|
Paths: Paths{AppDir: dir},
|
||||||
|
Config: domain.Config{JobsFile: filepath.Join("shared", "team.json"), LogsDir: "logs"},
|
||||||
|
}
|
||||||
|
|
||||||
|
store.applyConfigPaths()
|
||||||
|
|
||||||
|
if want := filepath.Join(dir, "shared", "team.json"); store.Paths.JobsPath != want {
|
||||||
|
t.Errorf("JobsPath: got %q, want %q", store.Paths.JobsPath, want)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(dir, "shared"); store.Paths.JobsDir != want {
|
||||||
|
t.Errorf("JobsDir: got %q, want %q", store.Paths.JobsDir, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobTimeoutRoundTripsThreeStates pins the on-disk encoding that keeps
|
||||||
|
// "inherit" and "no timeout" distinguishable: nil is omitted entirely, while an
|
||||||
|
// explicit 0 is written and read back as a set value.
|
||||||
|
func TestJobTimeoutRoundTripsThreeStates(t *testing.T) {
|
||||||
|
jobs := []domain.Job{
|
||||||
|
{ID: 1, Name: "Inherit", TimeoutSeconds: nil},
|
||||||
|
{ID: 2, Name: "No timeout", TimeoutSeconds: domain.TimeoutSecondsPtr(0)},
|
||||||
|
{ID: 3, Name: "Own", TimeoutSeconds: domain.TimeoutSecondsPtr(45)},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(domain.JobsFile{Jobs: jobs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := `"timeout_seconds":0`; !strings.Contains(string(data), want) {
|
||||||
|
t.Fatalf("explicit zero timeout should be written as %s:\n%s", want, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Jobs[0].TimeoutSeconds != nil {
|
||||||
|
t.Errorf("unset timeout should stay nil, got %d", *got.Jobs[0].TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if got.Jobs[1].TimeoutSeconds == nil || *got.Jobs[1].TimeoutSeconds != 0 {
|
||||||
|
t.Errorf("explicit zero timeout should survive the round trip, got %v", got.Jobs[1].TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if got.Jobs[2].TimeoutSeconds == nil || *got.Jobs[2].TimeoutSeconds != 45 {
|
||||||
|
t.Errorf("per-job timeout should survive the round trip, got %v", got.Jobs[2].TimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
|
func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||||
// Job carries only durable configuration; runtime state lives in
|
// Job carries only durable configuration; runtime state lives in
|
||||||
// domain.JobRuntime and is never marshalled. This guards against a future
|
// domain.JobRuntime and is never marshalled. This guards against a future
|
||||||
@@ -199,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,51 +24,82 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
|
||||||
var events []event
|
// job's own activity list. History is never persisted and every record carries
|
||||||
for _, current := range jobs {
|
// the run's full captured output, so an app left running in the tray — the mode
|
||||||
// At startup this is usually empty because jobs.json does not persist
|
// GoSentry is designed for — would otherwise hold every record of every run
|
||||||
// runtime logs. The function still centralizes the merge for future
|
// forever, and pay a full resort plus a full column-width rescan on each new
|
||||||
// history loading from log metadata.
|
// one. One job on @every 10s produces ~8 600 records a day.
|
||||||
if rt := runtimes[current.ID]; rt != nil {
|
const maxHistoryRows = 1000
|
||||||
events = append(events, rt.Logs...)
|
|
||||||
}
|
// 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
|
||||||
sort.SliceStable(events, func(left int, right int) bool {
|
// at a time instead of being recomputed from every row on every event, which
|
||||||
return events[left].Time < events[right].Time
|
// is what made the per-event cost grow with the number of rows.
|
||||||
})
|
type historyLog struct {
|
||||||
return events
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
|
func newHistoryLog(records []event) *historyLog {
|
||||||
// The minimum keeps the column readable when names are short or absent; the
|
h := &historyLog{records: trimHistory(records)}
|
||||||
// maximum stops a single very long file name from dominating the table (the
|
h.rescan()
|
||||||
// table still scrolls horizontally past it).
|
return h
|
||||||
const (
|
}
|
||||||
logColumnMinWidth = 240
|
|
||||||
logColumnMaxWidth = 520
|
|
||||||
logColumnPadding = 24
|
|
||||||
)
|
|
||||||
|
|
||||||
// logColumnWidth measures the widest Log cell value so the column can be sized
|
// trimHistory drops the oldest records past the cap. The tail of the backing
|
||||||
// to fit its content. Fyne tables do not auto-size columns, so without this the
|
// array is zeroed because a dropped record holds the run's whole output, which
|
||||||
// fixed width clips file names like "20260601-100000_SomeJobName.log".
|
// would otherwise stay reachable until the slice happens to be reallocated.
|
||||||
func logColumnWidth(events []event) float32 {
|
func trimHistory(records []event) []event {
|
||||||
width := float32(logColumnMinWidth)
|
if len(records) <= maxHistoryRows {
|
||||||
for _, current := range events {
|
return records
|
||||||
text := logFileName(current.LogFile)
|
}
|
||||||
if text == "" {
|
kept := copy(records, records[len(records)-maxHistoryRows:])
|
||||||
continue
|
for i := kept; i < len(records); i++ {
|
||||||
}
|
records[i] = event{}
|
||||||
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
|
}
|
||||||
if w > width {
|
return records[:kept]
|
||||||
width = w
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if width > logColumnMaxWidth {
|
}
|
||||||
width = logColumnMaxWidth
|
|
||||||
|
// 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 width
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -85,7 +114,7 @@ type historyHeader struct {
|
|||||||
func newHistoryHeader() *historyHeader {
|
func newHistoryHeader() *historyHeader {
|
||||||
h := &historyHeader{label: widget.NewLabel("")}
|
h := &historyHeader{label: widget.NewLabel("")}
|
||||||
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
h.label.Wrapping = fyne.TextTruncate
|
h.label.Truncation = fyne.TextTruncateClip
|
||||||
h.ExtendBaseWidget(h)
|
h.ExtendBaseWidget(h)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -104,46 +133,54 @@ func (h *historyHeader) SetText(text string) {
|
|||||||
h.label.SetText(text)
|
h.label.SetText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
// historyHeaders are the History table's column captions, in column order. The
|
||||||
|
// Time caption is built per update because it carries the sort direction arrow.
|
||||||
|
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||||
|
|
||||||
|
func newHistoryView(log *historyLog) (*fyne.Container, func()) {
|
||||||
descending := false
|
descending := false
|
||||||
headerText := func(id widget.TableCellID) string {
|
headerText := func(id widget.TableCellID) string {
|
||||||
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
|
||||||
if id.Row < 0 && id.Col == 0 {
|
if id.Row < 0 && id.Col == 0 {
|
||||||
if descending {
|
if descending {
|
||||||
return "Time ▼"
|
return "Time ▼"
|
||||||
}
|
}
|
||||||
return "Time ▲"
|
return "Time ▲"
|
||||||
}
|
}
|
||||||
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
|
if id.Row < 0 && id.Col >= 0 && id.Col < len(historyHeaders) {
|
||||||
return headers[id.Col]
|
return historyHeaders[id.Col]
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sortedEvents := func() []event {
|
|
||||||
result := append([]event(nil), (*events)...)
|
// rows is the sorted snapshot every callback below reads — both the length
|
||||||
sort.SliceStable(result, func(left int, right int) bool {
|
// callback and the cells, which must agree on the same slice. A full redraw
|
||||||
|
// issues one update call per visible cell, so sorting inside the cell
|
||||||
|
// callback re-sorted the whole event list a hundred times per Refresh.
|
||||||
|
// resort() is therefore the only place the order changes, and it runs once
|
||||||
|
// per redraw: at build time, on a sort toggle, and from refresh().
|
||||||
|
var rows []event
|
||||||
|
resort := func() {
|
||||||
|
rows = append(rows[:0], log.records...)
|
||||||
|
sort.SliceStable(rows, func(left int, right int) bool {
|
||||||
if descending {
|
if descending {
|
||||||
return result[left].Time > result[right].Time
|
return rows[left].Time > rows[right].Time
|
||||||
}
|
}
|
||||||
return result[left].Time < result[right].Time
|
return rows[left].Time < rows[right].Time
|
||||||
})
|
})
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
resort()
|
||||||
|
|
||||||
table := widget.NewTable(
|
table := widget.NewTable(
|
||||||
func() (int, int) {
|
func() (int, int) {
|
||||||
return len(*events), 6
|
return len(rows), len(historyHeaders)
|
||||||
},
|
},
|
||||||
func() fyne.CanvasObject {
|
func() fyne.CanvasObject {
|
||||||
label := widget.NewLabel("")
|
label := widget.NewLabel("")
|
||||||
label.Wrapping = fyne.TextTruncate
|
label.Truncation = fyne.TextTruncateClip
|
||||||
return label
|
return label
|
||||||
},
|
},
|
||||||
func(id widget.TableCellID, item fyne.CanvasObject) {
|
func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||||
label := item.(*widget.Label)
|
item.(*widget.Label).SetText(historyCellText(id, rows))
|
||||||
label.SetText(historyCellText(id, sortedEvents()))
|
|
||||||
label.TextStyle = fyne.TextStyle{}
|
|
||||||
label.Refresh()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
table.ShowHeaderRow = true
|
table.ShowHeaderRow = true
|
||||||
@@ -156,6 +193,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
|||||||
if id.Row < 0 && id.Col == 0 {
|
if id.Row < 0 && id.Col == 0 {
|
||||||
h.OnTapped = func() {
|
h.OnTapped = func() {
|
||||||
descending = !descending
|
descending = !descending
|
||||||
|
resort()
|
||||||
table.Refresh()
|
table.Refresh()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -166,18 +204,21 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
|||||||
table.OnSelected = func(id widget.TableCellID) {
|
table.OnSelected = func(id widget.TableCellID) {
|
||||||
table.Unselect(id)
|
table.Unselect(id)
|
||||||
}
|
}
|
||||||
table.SetColumnWidth(0, 150)
|
setColumnWidths := func() {
|
||||||
table.SetColumnWidth(1, 90)
|
for col, width := range log.columnWidths() {
|
||||||
table.SetColumnWidth(2, 170)
|
table.SetColumnWidth(col, width)
|
||||||
table.SetColumnWidth(3, 90)
|
}
|
||||||
table.SetColumnWidth(4, 260)
|
}
|
||||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
setColumnWidths()
|
||||||
|
|
||||||
// refresh recomputes the content-fit Log column width before redrawing, so
|
// refresh re-reads the event list into the sorted snapshot and re-applies
|
||||||
// newly recorded events with longer file names widen the column instead of
|
// the column widths before redrawing, so newly recorded events appear in
|
||||||
// being truncated.
|
// the current sort order and longer values widen their column instead of
|
||||||
|
// being truncated. The widths come from historyLog, which folded each new
|
||||||
|
// record in as it arrived — this does not rescan every row.
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
resort()
|
||||||
|
setColumnWidths()
|
||||||
table.Refresh()
|
table.Refresh()
|
||||||
}
|
}
|
||||||
return container.NewPadded(table), refresh
|
return container.NewPadded(table), refresh
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// textWidth measures how wide s renders at the theme's current body text size.
|
||||||
|
func textWidth(s string) float32 {
|
||||||
|
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||||
|
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||||
|
// doubled (one side each), so it follows text size and DPI.
|
||||||
|
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||||
|
|
||||||
|
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||||
|
// column: the minimum keeps a column readable when its values are short or
|
||||||
|
// absent, the maximum stops one very long value from dominating the table
|
||||||
|
// (the table still scrolls horizontally past it). Expressed as measured text
|
||||||
|
// rather than raw pixels so both follow the theme instead of drifting from it.
|
||||||
|
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
||||||
|
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
||||||
|
|
||||||
|
// textColumnWidth measures the widest of samples so a table column can be
|
||||||
|
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
||||||
|
// auto-size columns, so without this a fixed width clips values like
|
||||||
|
// "20260601-100000_SomeJobName.log" in the Log column.
|
||||||
|
func textColumnWidth(samples []string, min, max float32) float32 {
|
||||||
|
width := min
|
||||||
|
for _, text := range samples {
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if w := textWidth(text) + cellPadding(); w > width {
|
||||||
|
width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width > max {
|
||||||
|
width = max
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyTriggerSamples is the closed set of Trigger values History ever
|
||||||
|
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
||||||
|
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
||||||
|
// trigger here too if one is introduced there, or the column may clip it.
|
||||||
|
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
||||||
|
|
||||||
|
// historyStateSamples is the closed set of State values History ever shows:
|
||||||
|
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
||||||
|
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
||||||
|
// Add a new state here too if one is introduced in either place.
|
||||||
|
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
||||||
|
|
||||||
|
// historyTimeSample is the rendered form of the timestamp layout every event
|
||||||
|
// uses (see newEvent), so the Time column needs no content scan: its width is
|
||||||
|
// fixed by the format string.
|
||||||
|
const historyTimeSample = "2026-01-02 15:04:05"
|
||||||
|
|
||||||
|
// historyColumnWidths computes every column's width from the current sorted
|
||||||
|
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
||||||
|
// Detail and Log are free text, so their width tracks the values actually
|
||||||
|
// present, bounded the same way the Log column always was.
|
||||||
|
func historyColumnWidths(rows []event) [6]float32 {
|
||||||
|
var content [3][]string
|
||||||
|
for i := range content {
|
||||||
|
content[i] = make([]string, 0, len(rows))
|
||||||
|
}
|
||||||
|
for _, current := range rows {
|
||||||
|
for i, value := range historyContentValues(current) {
|
||||||
|
content[i] = append(content[i], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||||
|
widths := [6]float32{
|
||||||
|
0: textWidth(historyTimeSample) + cellPadding(),
|
||||||
|
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||||
|
3: textColumnWidth(historyStateSamples, min, max),
|
||||||
|
}
|
||||||
|
for i, col := range historyContentCols {
|
||||||
|
widths[col] = textColumnWidth(content[i], min, max)
|
||||||
|
}
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLastJobLogsCapsAndCopies(t *testing.T) {
|
||||||
|
logs := []event{
|
||||||
|
{Time: "1", JobName: "a"},
|
||||||
|
{Time: "2", JobName: "b"},
|
||||||
|
{Time: "3", JobName: "c"},
|
||||||
|
{Time: "4", JobName: "d"},
|
||||||
|
}
|
||||||
|
got := lastJobLogs(logs)
|
||||||
|
if len(got) != maxJobActivityRows {
|
||||||
|
t.Fatalf("len = %d, want %d", len(got), maxJobActivityRows)
|
||||||
|
}
|
||||||
|
for i, want := range []string{"1", "2", "3"} {
|
||||||
|
if got[i].Time != want {
|
||||||
|
t.Errorf("got[%d].Time = %q, want %q", i, got[i].Time, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logs[0].Time = "mutated"
|
||||||
|
if got[0].Time == "mutated" {
|
||||||
|
t.Error("lastJobLogs must return a defensive copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastJobLogsEmpty(t *testing.T) {
|
||||||
|
if got := lastJobLogs(nil); len(got) != 0 {
|
||||||
|
t.Errorf("nil input: got %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexOfID(t *testing.T) {
|
||||||
|
jobs := []job{
|
||||||
|
{ID: 10, Name: "A"},
|
||||||
|
{ID: 20, Name: "B"},
|
||||||
|
}
|
||||||
|
if got := indexOfID(jobs, 20); got != 1 {
|
||||||
|
t.Errorf("found: got %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := indexOfID(jobs, 99); got != -1 {
|
||||||
|
t.Errorf("missing: got %d, want -1", got)
|
||||||
|
}
|
||||||
|
if got := indexOfID(nil, 1); got != -1 {
|
||||||
|
t.Errorf("empty slice: got %d, want -1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryCellText(t *testing.T) {
|
||||||
|
events := []event{{
|
||||||
|
Time: "2026-06-01 12:00:00",
|
||||||
|
Trigger: "",
|
||||||
|
JobName: "Job",
|
||||||
|
State: "OK",
|
||||||
|
Detail: "done",
|
||||||
|
LogFile: `/logs/20260601-120000_Job.log`,
|
||||||
|
}}
|
||||||
|
cases := []struct {
|
||||||
|
col int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "2026-06-01 12:00:00"},
|
||||||
|
{1, "Unknown"},
|
||||||
|
{2, "Job"},
|
||||||
|
{3, "OK"},
|
||||||
|
{4, "done"},
|
||||||
|
{5, "20260601-120000_Job.log"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := historyCellText(widget.TableCellID{Row: 0, Col: tc.col}, events)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("col %d: got %q, want %q", tc.col, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := historyCellText(widget.TableCellID{Row: -1, Col: 0}, events); got != "" {
|
||||||
|
t.Errorf("header row: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := historyCellText(widget.TableCellID{Row: 99, Col: 0}, events); got != "" {
|
||||||
|
t.Errorf("out of range row: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogFileName(t *testing.T) {
|
||||||
|
cases := []struct{ path, want string }{
|
||||||
|
{"", ""},
|
||||||
|
{" ", ""},
|
||||||
|
{`C:\logs\run.log`, "run.log"},
|
||||||
|
{"/var/logs/2026/job.log", "job.log"},
|
||||||
|
{"plain.log", "plain.log"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := logFileName(tc.path); got != tc.want {
|
||||||
|
t.Errorf("logFileName(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistorySortToggleKeepsRowsInSync is the regression guard for F11: the
|
||||||
|
// table now reads one cached sorted snapshot instead of re-sorting inside every
|
||||||
|
// cell callback, so the length callback and the cells have to be refilled
|
||||||
|
// together. If either the sort toggle or refresh stops calling resort(), the
|
||||||
|
// row count and the cell contents disagree — which no compiler check catches.
|
||||||
|
func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
events := []event{
|
||||||
|
{Time: "2026-06-01 10:00:00", JobName: "A"},
|
||||||
|
{Time: "2026-06-01 11:00:00", JobName: "B"},
|
||||||
|
{Time: "2026-06-01 12:00:00", JobName: "C"},
|
||||||
|
}
|
||||||
|
log := newHistoryLog(events)
|
||||||
|
content, refresh := newHistoryView(log)
|
||||||
|
table, ok := content.Objects[0].(*widget.Table)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("history view does not wrap a table")
|
||||||
|
}
|
||||||
|
|
||||||
|
rowCount := func() int {
|
||||||
|
t.Helper()
|
||||||
|
rows, cols := table.Length()
|
||||||
|
if cols != len(historyHeaders) {
|
||||||
|
t.Errorf("column count = %d, want %d", cols, len(historyHeaders))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
// Column 2 is the Job name, the field these fixtures vary.
|
||||||
|
jobAt := func(row int) string {
|
||||||
|
t.Helper()
|
||||||
|
cell := table.CreateCell()
|
||||||
|
table.UpdateCell(widget.TableCellID{Row: row, Col: 2}, cell)
|
||||||
|
return cell.(*widget.Label).Text
|
||||||
|
}
|
||||||
|
// The sort toggle lives on the Time header cell, which is only wired up
|
||||||
|
// when UpdateHeader runs for it.
|
||||||
|
header := table.CreateHeader()
|
||||||
|
table.UpdateHeader(widget.TableCellID{Row: -1, Col: 0}, header)
|
||||||
|
timeHeader, ok := header.(*historyHeader)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("history table header is not a historyHeader")
|
||||||
|
}
|
||||||
|
assertOrder := func(when string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := rowCount(); got != len(want) {
|
||||||
|
t.Fatalf("%s: row count = %d, want %d", when, got, len(want))
|
||||||
|
}
|
||||||
|
for row, name := range want {
|
||||||
|
if got := jobAt(row); got != name {
|
||||||
|
t.Errorf("%s: row %d = %q, want %q", when, row, got, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOrder("ascending", "A", "B", "C")
|
||||||
|
|
||||||
|
test.Tap(timeHeader)
|
||||||
|
assertOrder("descending", "C", "B", "A")
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"})
|
||||||
|
refresh()
|
||||||
|
assertOrder("descending after refresh", "D", "C", "B", "A")
|
||||||
|
|
||||||
|
test.Tap(timeHeader)
|
||||||
|
assertOrder("ascending after refresh", "A", "B", "C", "D")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryCellTemplateIsPlainText guards the dropped per-cell TextStyle
|
||||||
|
// assignment: the template must already carry the zero style, since nothing
|
||||||
|
// resets it any more.
|
||||||
|
func TestHistoryCellTemplateIsPlainText(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
content, _ := newHistoryView(newHistoryLog(nil))
|
||||||
|
table := content.Objects[0].(*widget.Table)
|
||||||
|
label, ok := table.CreateCell().(*widget.Label)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("history cell template is not a label")
|
||||||
|
}
|
||||||
|
if label.TextStyle != (fyne.TextStyle{}) {
|
||||||
|
t.Errorf("cell template TextStyle = %+v, want the zero value", label.TextStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTextColumnWidthClamps covers the three shapes textColumnWidth has to
|
||||||
|
// handle: a sample narrower than min, one that lands between the bounds, and
|
||||||
|
// one wide enough to hit the max cap.
|
||||||
|
func TestTextColumnWidthClamps(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
min, max := float32(50), float32(120)
|
||||||
|
if got := textColumnWidth([]string{"x"}, min, max); got != min {
|
||||||
|
t.Errorf("below-min sample: got %v, want the floor %v", got, min)
|
||||||
|
}
|
||||||
|
inRange := textWidth("mid-sized value") + cellPadding()
|
||||||
|
if inRange <= min || inRange >= max {
|
||||||
|
t.Skip("fixture sample no longer lands strictly between the bounds under this theme")
|
||||||
|
}
|
||||||
|
if got := textColumnWidth([]string{"mid-sized value"}, min, max); got != inRange {
|
||||||
|
t.Errorf("in-range sample: got %v, want %v", got, inRange)
|
||||||
|
}
|
||||||
|
if got := textColumnWidth([]string{strings.Repeat("0", 200)}, min, max); got != max {
|
||||||
|
t.Errorf("above-max sample: got %v, want the cap %v", got, max)
|
||||||
|
}
|
||||||
|
if got := textColumnWidth(nil, min, max); got != min {
|
||||||
|
t.Errorf("no samples: got %v, want the floor %v", got, min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryColumnsFitTheirContent guards F6/F14: every column must be at
|
||||||
|
// least as wide as its widest known or actually-present value, at the default
|
||||||
|
// theme and at a scaled one, so nothing that used to be a pixel constant
|
||||||
|
// clips again.
|
||||||
|
func TestHistoryColumnsFitTheirContent(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
rows := []event{
|
||||||
|
{
|
||||||
|
Time: "2026-06-01 12:00:00",
|
||||||
|
Trigger: "Schedule",
|
||||||
|
JobName: "A moderately long job name for width testing",
|
||||||
|
State: "Jobs loaded",
|
||||||
|
Detail: "A somewhat longer detail message describing what happened",
|
||||||
|
LogFile: `/logs/20260601-120000_SomeJobName.log`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
check := func(when string) {
|
||||||
|
t.Helper()
|
||||||
|
widths := historyColumnWidths(rows)
|
||||||
|
samples := [][]string{
|
||||||
|
{historyTimeSample},
|
||||||
|
historyTriggerSamples,
|
||||||
|
{rows[0].JobName},
|
||||||
|
historyStateSamples,
|
||||||
|
{rows[0].Detail},
|
||||||
|
{logFileName(rows[0].LogFile)},
|
||||||
|
}
|
||||||
|
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||||
|
for col, colSamples := range samples {
|
||||||
|
want := textColumnWidth(colSamples, min, max)
|
||||||
|
if col == 0 {
|
||||||
|
want = textWidth(historyTimeSample) + cellPadding()
|
||||||
|
}
|
||||||
|
if widths[col] < want {
|
||||||
|
t.Errorf("%s: column %d width = %v, want at least %v", when, col, widths[col], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check("default theme")
|
||||||
|
testApp.Settings().SetTheme(test.NewTheme())
|
||||||
|
check("scaled theme")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryLogCapsRecords is the regression guard for the unbounded History
|
||||||
|
// list: an app left in the tray records thousands of runs a day, each carrying
|
||||||
|
// the run's whole captured output, so the list must drop the oldest instead of
|
||||||
|
// growing forever.
|
||||||
|
func TestHistoryLogCapsRecords(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
log := newHistoryLog(nil)
|
||||||
|
for i := 0; i < maxHistoryRows+25; i++ {
|
||||||
|
log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)})
|
||||||
|
}
|
||||||
|
if len(log.records) != maxHistoryRows {
|
||||||
|
t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows)
|
||||||
|
}
|
||||||
|
if got, want := log.records[0].JobName, "Job 25"; got != want {
|
||||||
|
t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want)
|
||||||
|
}
|
||||||
|
last := log.records[len(log.records)-1].JobName
|
||||||
|
if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want {
|
||||||
|
t.Errorf("newest record = %q, want %q", last, want)
|
||||||
|
}
|
||||||
|
// A list handed in above the cap is trimmed too, not only one grown into it.
|
||||||
|
oversized := make([]event, maxHistoryRows+10)
|
||||||
|
if got := len(newHistoryLog(oversized).records); got != maxHistoryRows {
|
||||||
|
t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while
|
||||||
|
// every measured record is still in the list, folding each one in as it
|
||||||
|
// arrives must give exactly what rescanning every row would, or the cheaper
|
||||||
|
// path would clip values the old one showed.
|
||||||
|
func TestHistoryLogWidthsMatchAFullScan(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
log := newHistoryLog(nil)
|
||||||
|
for _, record := range []event{
|
||||||
|
{Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`},
|
||||||
|
{Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`},
|
||||||
|
{Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`},
|
||||||
|
} {
|
||||||
|
log.add(record)
|
||||||
|
}
|
||||||
|
if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want {
|
||||||
|
t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the
|
||||||
|
// rule: widths only grow. Dropping the record that set a column's width must
|
||||||
|
// not narrow the column, because the rows on screen were laid out against it.
|
||||||
|
func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
log := newHistoryLog(nil)
|
||||||
|
log.add(event{Time: "1", JobName: "A job name long enough to widen its column"})
|
||||||
|
widest := log.columnWidths()[2]
|
||||||
|
for i := 0; i < maxHistoryRows; i++ {
|
||||||
|
log.add(event{Time: "t", JobName: "x"})
|
||||||
|
}
|
||||||
|
if got := log.columnWidths()[2]; got != widest {
|
||||||
|
t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold
|
||||||
|
// cannot handle: every stored width was measured at the old text size, so a
|
||||||
|
// theme change has to fall back to a full rescan.
|
||||||
|
func TestHistoryLogRescansOnThemeChange(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
log := newHistoryLog([]event{
|
||||||
|
{Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"},
|
||||||
|
})
|
||||||
|
before := log.columnWidths()
|
||||||
|
|
||||||
|
testApp.Settings().SetTheme(test.NewTheme())
|
||||||
|
after := log.columnWidths()
|
||||||
|
if after == before {
|
||||||
|
t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics")
|
||||||
|
}
|
||||||
|
if want := historyColumnWidths(log.records); after != want {
|
||||||
|
t.Errorf("widths after theme change = %v, want the rescanned %v", after, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
|
||||||
|
ev := newEvent(1, "Job", "OK", "detail")
|
||||||
|
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
||||||
|
t.Errorf("timestamp %q is not in expected layout: %v", ev.Time, err)
|
||||||
|
}
|
||||||
|
if ev.Trigger != "UI" || ev.JobID != 1 || ev.JobName != "Job" {
|
||||||
|
t.Errorf("unexpected event fields: %+v", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
@@ -34,11 +35,13 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
||||||
commandEntry.SetText(current.Command)
|
commandEntry.SetText(current.Command)
|
||||||
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFile(w, commandEntry)
|
chooseFile(w, commandEntry, nil)
|
||||||
})
|
})
|
||||||
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
|
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
|
||||||
argumentsEntry := widget.NewMultiLineEntry()
|
argumentsEntry := widget.NewMultiLineEntry()
|
||||||
argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
|
// One argument per line is the whole point of the field: each line is passed
|
||||||
|
// to the process verbatim, so paths with spaces need no quoting.
|
||||||
|
argumentsEntry.SetPlaceHolder("One argument per line, no quotes:\n--input\n" + `C:\Data\My Files\input.txt`)
|
||||||
argumentsEntry.SetText(current.Arguments)
|
argumentsEntry.SetText(current.Arguments)
|
||||||
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
|
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
|
||||||
startOnly.SetChecked(current.StartOnly)
|
startOnly.SetChecked(current.StartOnly)
|
||||||
@@ -53,6 +56,11 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
overlapSelected = current.OverlapPolicy
|
overlapSelected = current.OverlapPolicy
|
||||||
}
|
}
|
||||||
overlapSelect.SetSelected(overlapSelected)
|
overlapSelect.SetSelected(overlapSelected)
|
||||||
|
timeoutEntry := widget.NewEntry()
|
||||||
|
timeoutEntry.SetPlaceHolder("Empty = global default, 0 = no timeout")
|
||||||
|
if current.TimeoutSeconds != nil {
|
||||||
|
timeoutEntry.SetText(strconv.Itoa(*current.TimeoutSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
form := dialog.NewForm(
|
form := dialog.NewForm(
|
||||||
title,
|
title,
|
||||||
@@ -66,6 +74,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
widget.NewFormItem("Arguments", argumentsEntry),
|
widget.NewFormItem("Arguments", argumentsEntry),
|
||||||
widget.NewFormItem("", startOnly),
|
widget.NewFormItem("", startOnly),
|
||||||
widget.NewFormItem("Overlap policy", overlapSelect),
|
widget.NewFormItem("Overlap policy", overlapSelect),
|
||||||
|
widget.NewFormItem("Timeout (s)", timeoutEntry),
|
||||||
widget.NewFormItem("", enabled),
|
widget.NewFormItem("", enabled),
|
||||||
},
|
},
|
||||||
func(saved bool) {
|
func(saved bool) {
|
||||||
@@ -82,6 +91,18 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
|
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// An empty timeout inherits the global default (nil); an explicit 0
|
||||||
|
// means "no timeout" and does not inherit; anything else must be a
|
||||||
|
// positive whole number of seconds.
|
||||||
|
var timeoutSeconds *int
|
||||||
|
if trimmed := strings.TrimSpace(timeoutEntry.Text); trimmed != "" {
|
||||||
|
parsed, err := strconv.Atoi(trimmed)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
dialog.ShowError(fmt.Errorf("timeout must be 0 (no timeout) or a positive number of seconds, or empty to use the global default"), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeoutSeconds = domain.TimeoutSecondsPtr(parsed)
|
||||||
|
}
|
||||||
current.Name = strings.TrimSpace(name.Text)
|
current.Name = strings.TrimSpace(name.Text)
|
||||||
current.Folder = strings.TrimSpace(folderEntry.Text)
|
current.Folder = strings.TrimSpace(folderEntry.Text)
|
||||||
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
|
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
|
||||||
@@ -93,6 +114,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
if current.OverlapPolicy == overlapPolicyInherit {
|
if current.OverlapPolicy == overlapPolicyInherit {
|
||||||
current.OverlapPolicy = ""
|
current.OverlapPolicy = ""
|
||||||
}
|
}
|
||||||
|
current.TimeoutSeconds = timeoutSeconds
|
||||||
// The dialog only edits durable configuration. Runtime status is
|
// The dialog only edits durable configuration. Runtime status is
|
||||||
// initialized (new jobs) or updated (edits) by the caller against the
|
// initialized (new jobs) or updated (edits) by the caller against the
|
||||||
// runtime map, keyed by job ID.
|
// runtime map, keyed by job ID.
|
||||||
@@ -100,6 +122,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
)
|
)
|
||||||
form.Resize(fyne.NewSize(640, 460))
|
form.Resize(fyne.NewSize(640, 500))
|
||||||
form.Show()
|
form.Show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -16,23 +14,36 @@ import (
|
|||||||
|
|
||||||
const allFolders = "All"
|
const allFolders = "All"
|
||||||
const noFolder = "No folder"
|
const noFolder = "No folder"
|
||||||
const minJobsSidebarWidth float32 = 400
|
|
||||||
|
|
||||||
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
|
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
|
||||||
// entries. The full per-job history (up to maxJobLogs) remains in the History
|
// entries. The full per-job history (up to maxJobLogs) remains in the History
|
||||||
// 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
|
||||||
|
|
||||||
// detailRowSpacing is the (negative) gap applied between metadata rows in the
|
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
|
||||||
// details panel. Pulling rows together overlaps the labels' built-in vertical
|
// (list mode and the scheduler pause label), and the jobsViewState the widgets
|
||||||
// padding, tightening the block so it fits comfortably on 720p screens.
|
// read. It replaces a single constructor whose dozen closures shared seven
|
||||||
const detailRowSpacing float32 = -8
|
// 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
|
||||||
|
|
||||||
// jobRowSpacing is the (negative) gap between the name, metadata, and status
|
list *widget.List
|
||||||
// lines within each job list row. Like the details panel, it overlaps the
|
folderSelect *widget.Select
|
||||||
// labels' built-in vertical padding so each row reads as one compact block and
|
viewButton *widget.Button
|
||||||
// more jobs are visible without scrolling.
|
stopAllButton *widget.Button
|
||||||
const jobRowSpacing float32 = -8
|
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
|
||||||
@@ -40,255 +51,156 @@ const jobRowSpacing float32 = -8
|
|||||||
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
||||||
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
||||||
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||||
jobs := svc.Jobs()
|
config := svc.Config()
|
||||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
v := &jobsView{
|
||||||
syncFromService := func() {
|
w: w,
|
||||||
jobs = svc.Jobs()
|
svc: svc,
|
||||||
for id := range runtimes {
|
state: newJobsViewState(svc),
|
||||||
delete(runtimes, id)
|
listView: config.JobListView,
|
||||||
}
|
paused: config.Paused,
|
||||||
for _, current := range jobs {
|
|
||||||
if rt := svc.Runtime(current.ID); rt != nil {
|
|
||||||
runtimes[current.ID] = rt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
syncFromService()
|
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||||
runtimeFor := func(index int) *domain.JobRuntime {
|
v.updateDetails()
|
||||||
if index < 0 || index >= len(jobs) {
|
|
||||||
return &domain.JobRuntime{}
|
// Build order follows what refresh() touches: the folder select fires its
|
||||||
}
|
// OnChanged from SetSelected below, which refreshes, so every widget that
|
||||||
if rt := runtimes[jobs[index].ID]; rt != nil {
|
// refresh() reaches has to exist by then.
|
||||||
return rt
|
v.list = v.newList()
|
||||||
}
|
v.viewButton = v.newViewToggle()
|
||||||
return &domain.JobRuntime{}
|
globalControls := v.newGlobalControls()
|
||||||
|
v.folderSelect = v.newFolderSelect()
|
||||||
|
v.folderSelect.SetSelected(v.state.folder)
|
||||||
|
v.syncListSelection()
|
||||||
|
|
||||||
|
return v.assemble(globalControls), v.refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh re-reads the Service and redraws the whole view. It is the single
|
||||||
|
// entry point for "something changed": the toolbar handlers call it after a
|
||||||
|
// successful operation, and mainwindow's event observer calls it for everything
|
||||||
|
// else.
|
||||||
|
func (v *jobsView) refresh() {
|
||||||
|
v.state.sync()
|
||||||
|
// The pause state is Service-owned and can change from outside this view, so
|
||||||
|
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||||
|
// what makes this view a consumer of SchedulerStateChanged.
|
||||||
|
v.applySchedulerState(v.svc.Config().Paused)
|
||||||
|
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
||||||
|
// paths do), so refreshing the activity list again here would redraw it twice
|
||||||
|
// per call.
|
||||||
|
v.updateDetails()
|
||||||
|
v.list.Refresh()
|
||||||
|
v.syncListSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDetails repopulates the details pane from the current selection.
|
||||||
|
func (v *jobsView) updateDetails() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
// A folder filter can temporarily leave no selectable rows. Clearing the
|
||||||
|
// details panel avoids showing stale information for a hidden job.
|
||||||
|
v.dp.clear()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// Overlap policy and the default timeout are global settings that can change
|
||||||
|
// from the Settings tab while this view is open, so they are re-read on every
|
||||||
|
// update rather than captured once at construction.
|
||||||
|
config := v.svc.Config()
|
||||||
|
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
selected := 0
|
// syncListSelection points the list's highlight at the selected job. It is what
|
||||||
selectedFolder := allFolders
|
// keeps the highlight and the details pane describing the same job when the row
|
||||||
schedulerPaused := svc.Store().Config.Paused
|
// a job sits in moves — a job created or deleted above it, a folder filter
|
||||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
// 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
|
||||||
dp := newDetailsPanel(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
|
// fight the user's scrolling.
|
||||||
|
func (v *jobsView) syncListSelection() {
|
||||||
updateDetails := func(index int) {
|
row := v.state.displayRow()
|
||||||
if index < 0 || index >= len(jobs) {
|
if row < 0 {
|
||||||
// A folder filter can temporarily leave no selectable rows. Clearing
|
v.list.UnselectAll()
|
||||||
// the details panel avoids showing stale information for a hidden job.
|
return
|
||||||
dp.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selected = index
|
|
||||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
|
|
||||||
}
|
}
|
||||||
|
v.list.Select(row)
|
||||||
|
}
|
||||||
|
|
||||||
// list and folderSelect are declared early so closures below can reference
|
// rebuildFolders re-derives the folder filter's options from the current jobs.
|
||||||
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
// Creating, editing, and deleting a job can all add or remove a folder.
|
||||||
var list *widget.List
|
func (v *jobsView) rebuildFolders() {
|
||||||
var folderSelect *widget.Select
|
v.folderSelect.Options = folderOptions(v.state.jobs)
|
||||||
|
v.folderSelect.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
refreshView := func() {
|
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
|
||||||
syncFromService()
|
// the details pane into the master/detail split the tab shows.
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
|
||||||
updateDetails(selected)
|
// The whole filter is one row: caption on the left, view toggle on the right,
|
||||||
dp.logs.Refresh()
|
// select filling what is left. The border layout gives both edges their
|
||||||
if list != nil {
|
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
||||||
list.Refresh()
|
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
}
|
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
|
||||||
}
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
|
||||||
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
|
||||||
|
|
||||||
list = widget.NewList(
|
// A split rather than a Border left slot: the border pinned the sidebar at its
|
||||||
func() int { return len(filteredJobs) },
|
// MinSize forever, so the user could never trade list width for detail width.
|
||||||
func() fyne.CanvasObject {
|
// The divider lets either pane grow, and neither can be dragged below its own
|
||||||
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
// content minimum.
|
||||||
meta := widget.NewLabel("schedule")
|
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
|
||||||
status := widget.NewLabel("status")
|
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
||||||
return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status)
|
return panel
|
||||||
},
|
}
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
|
||||||
row := item.(*fyne.Container)
|
|
||||||
name := row.Objects[0].(*widget.Label)
|
|
||||||
meta := row.Objects[1].(*widget.Label)
|
|
||||||
status := row.Objects[2].(*widget.Label)
|
|
||||||
|
|
||||||
current := jobs[filteredJobs[id]]
|
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
|
||||||
name.SetText(current.Name)
|
// and, when the selected job is no longer visible, moves the selection to the
|
||||||
// Keep each row compact: folder, schedule, and command are shown in one
|
// first row that is (see jobsViewState.applyFilter).
|
||||||
// metadata line so the left pane stays useful even with many jobs.
|
func (v *jobsView) newFolderSelect() *widget.Select {
|
||||||
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
|
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
|
||||||
status.SetText(app.StatusText(current, runtimes[current.ID]))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
list.OnSelected = func(id widget.ListItemID) {
|
|
||||||
if id < 0 || id >= len(filteredJobs) {
|
|
||||||
updateDetails(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateDetails(filteredJobs[id])
|
|
||||||
}
|
|
||||||
list.Select(selected)
|
|
||||||
|
|
||||||
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
|
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedFolder = value
|
v.state.applyFilter(value)
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
v.refresh()
|
||||||
list.Refresh()
|
|
||||||
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.
|
|
||||||
selected = -1
|
|
||||||
updateDetails(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selected = filteredJobs[0]
|
|
||||||
list.Select(0)
|
|
||||||
refreshView()
|
|
||||||
})
|
})
|
||||||
folderSelect.SetSelected(selectedFolder)
|
}
|
||||||
|
|
||||||
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
// newGlobalControls builds the pause control row that sits above the filter.
|
||||||
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
|
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
|
||||||
created, err := svc.CreateJob(saved)
|
v.schedulerState = widget.NewLabel("")
|
||||||
if err != nil {
|
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
|
||||||
dialog.ShowError(err, w)
|
v.applySchedulerState(v.paused)
|
||||||
return
|
v.stopAllButton.OnTapped = func() {
|
||||||
}
|
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
||||||
syncFromService()
|
// and emits the activity record the observer logs. refresh re-derives the
|
||||||
folderSelect.Options = folderOptions(jobs)
|
// pause state from the Service, so a failed save leaves the control showing
|
||||||
folderSelect.Refresh()
|
// what actually happened.
|
||||||
targetFolder := filterValue(created.Folder)
|
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
|
||||||
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
dialog.ShowError(err, v.w)
|
||||||
selectedFolder = targetFolder
|
return
|
||||||
folderSelect.SetSelected(targetFolder)
|
}
|
||||||
}
|
v.refresh()
|
||||||
selected = indexOfID(jobs, created.ID)
|
}
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
// The row sits directly under the tab bar with no AppTabs inset, while the
|
||||||
list.Refresh()
|
// default VBox gap below it is one theme padding — add the same on top so
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
// the button is not flush against the tabs.
|
||||||
refreshView()
|
return container.New(
|
||||||
})
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
||||||
})
|
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
|
||||||
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
)
|
||||||
if selected < 0 || selected >= len(jobs) {
|
}
|
||||||
return
|
|
||||||
}
|
// applySchedulerState is the one place that draws the pause control and its
|
||||||
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
// status text from a pause value, so refresh can drive it from whatever the
|
||||||
saved.ID = jobs[selected].ID
|
// Service reports instead of only the tap handler mirroring its own toggle.
|
||||||
if err := svc.UpdateJob(saved); err != nil {
|
func (v *jobsView) applySchedulerState(paused bool) {
|
||||||
dialog.ShowError(err, w)
|
v.paused = paused
|
||||||
return
|
if paused {
|
||||||
}
|
v.schedulerState.SetText("Scheduler paused")
|
||||||
syncFromService()
|
v.stopAllButton.SetText("Enable auto")
|
||||||
folderSelect.Options = folderOptions(jobs)
|
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||||
folderSelect.Refresh()
|
return
|
||||||
list.Refresh()
|
}
|
||||||
refreshView()
|
v.schedulerState.SetText("Scheduler running")
|
||||||
})
|
v.stopAllButton.SetText("Disable auto")
|
||||||
})
|
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
|
||||||
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".
|
|
||||||
// RunNow still refuses an already-running job (it returns an error); the UI
|
|
||||||
// has always ignored that case silently, so the run simply does not start.
|
|
||||||
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list.Refresh()
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
list.Refresh()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
list.Refresh()
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
list.Refresh()
|
|
||||||
if selected >= 0 {
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
}, w)
|
|
||||||
})
|
|
||||||
|
|
||||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
|
||||||
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
|
|
||||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar)
|
|
||||||
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
|
|
||||||
|
|
||||||
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
|
||||||
panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(dp.container()))
|
|
||||||
return panel, refreshView
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,7 @@ type detailsPanel struct {
|
|||||||
arguments *widget.Label
|
arguments *widget.Label
|
||||||
runMode *widget.Label
|
runMode *widget.Label
|
||||||
overlapPolicy *widget.Label
|
overlapPolicy *widget.Label
|
||||||
|
timeout *widget.Label
|
||||||
lastRun *widget.Label
|
lastRun *widget.Label
|
||||||
nextRun *widget.Label
|
nextRun *widget.Label
|
||||||
state *widget.Label
|
state *widget.Label
|
||||||
@@ -34,7 +36,7 @@ type detailsPanel struct {
|
|||||||
selectedLogs []event
|
selectedLogs []event
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) *detailsPanel {
|
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) *detailsPanel {
|
||||||
d := &detailsPanel{
|
d := &detailsPanel{
|
||||||
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
folder: newJobDetailLabel(""),
|
folder: newJobDetailLabel(""),
|
||||||
@@ -43,6 +45,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
|||||||
arguments: newJobDetailLabel(""),
|
arguments: newJobDetailLabel(""),
|
||||||
runMode: newJobDetailLabel(""),
|
runMode: newJobDetailLabel(""),
|
||||||
overlapPolicy: newJobDetailLabel(""),
|
overlapPolicy: newJobDetailLabel(""),
|
||||||
|
timeout: newJobDetailLabel(""),
|
||||||
lastRun: newJobDetailLabel(""),
|
lastRun: newJobDetailLabel(""),
|
||||||
nextRun: newJobDetailLabel(""),
|
nextRun: newJobDetailLabel(""),
|
||||||
state: newJobDetailLabel(""),
|
state: newJobDetailLabel(""),
|
||||||
@@ -57,23 +60,29 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
|||||||
// The height here is only a floor: the scroll grows to fill whatever space the
|
// The height here is only a floor: the scroll grows to fill whatever space the
|
||||||
// border layout gives it, so keep the minimum small so the whole window can be
|
// border layout gives it, so keep the minimum small so the whole window can be
|
||||||
// shrunk on short (720p) screens. Long output stays reachable by scrolling.
|
// shrunk on short (720p) screens. Long output stays reachable by scrolling.
|
||||||
|
// The width, unlike the height, is load-bearing outside this widget: it is the
|
||||||
|
// details pane's widest minimum, so it is also what keeps the metadata value
|
||||||
|
// column non-empty — captionValueLayout hands the value whatever is left after
|
||||||
|
// the caption and has no floor of its own (see its comment in layout.go).
|
||||||
|
// Lowering it narrows those values with nothing to warn about it; the user can
|
||||||
|
// only widen them, by dragging the jobs split divider left.
|
||||||
d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 70))
|
d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 70))
|
||||||
d.logs = widget.NewList(
|
d.logs = widget.NewList(
|
||||||
func() int { return len(d.selectedLogs) },
|
func() int { return len(d.selectedLogs) },
|
||||||
func() fyne.CanvasObject {
|
func() fyne.CanvasObject {
|
||||||
l := widget.NewLabel("log")
|
l := widget.NewLabel("log")
|
||||||
l.Wrapping = fyne.TextTruncate
|
l.Truncation = fyne.TextTruncateClip
|
||||||
return l
|
return l
|
||||||
},
|
},
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
|
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
d.update(firstJob, rt, globalOverlapPolicy)
|
d.update(firstJob, rt, globalOverlapPolicy, globalTimeout)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) {
|
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) {
|
||||||
d.title.SetText(j.Name)
|
d.title.SetText(j.Name)
|
||||||
d.folder.SetText(app.DisplayFolder(j.Folder))
|
d.folder.SetText(app.DisplayFolder(j.Folder))
|
||||||
d.schedule.SetText(j.Schedule)
|
d.schedule.SetText(j.Schedule)
|
||||||
@@ -81,6 +90,7 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy
|
|||||||
d.arguments.SetText(app.DisplayArguments(j.Arguments))
|
d.arguments.SetText(app.DisplayArguments(j.Arguments))
|
||||||
d.runMode.SetText(app.DisplayRunMode(j))
|
d.runMode.SetText(app.DisplayRunMode(j))
|
||||||
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
||||||
|
d.timeout.SetText(app.DisplayTimeout(j, globalTimeout))
|
||||||
d.lastRun.SetText(rt.LastRun)
|
d.lastRun.SetText(rt.LastRun)
|
||||||
d.nextRun.SetText(rt.NextRun)
|
d.nextRun.SetText(rt.NextRun)
|
||||||
d.state.SetText(rt.LastState)
|
d.state.SetText(rt.LastState)
|
||||||
@@ -101,6 +111,7 @@ func (d *detailsPanel) clear() {
|
|||||||
d.arguments.SetText("")
|
d.arguments.SetText("")
|
||||||
d.runMode.SetText("")
|
d.runMode.SetText("")
|
||||||
d.overlapPolicy.SetText("")
|
d.overlapPolicy.SetText("")
|
||||||
|
d.timeout.SetText("")
|
||||||
d.lastRun.SetText("")
|
d.lastRun.SetText("")
|
||||||
d.nextRun.SetText("")
|
d.nextRun.SetText("")
|
||||||
d.state.SetText("")
|
d.state.SetText("")
|
||||||
@@ -110,20 +121,59 @@ func (d *detailsPanel) clear() {
|
|||||||
d.logs.Refresh()
|
d.logs.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detailRowSpec pairs a metadata caption with the widget that shows its value.
|
||||||
|
// metadataRows and container derive both the caption column width and the row
|
||||||
|
// layout from this single list, so a row added to one is never forgotten in
|
||||||
|
// the other.
|
||||||
|
type detailRowSpec struct {
|
||||||
|
caption string
|
||||||
|
value fyne.CanvasObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataRows lists the details pane's metadata rows in display order. It is
|
||||||
|
// the single source both the caption width measurement and the row layout in
|
||||||
|
// container() read, so a twelfth row added here cannot silently go unmeasured
|
||||||
|
// or unlaid-out the way two separately maintained lists could.
|
||||||
|
func (d *detailsPanel) metadataRows() []detailRowSpec {
|
||||||
|
return []detailRowSpec{
|
||||||
|
{"Folder", d.folder},
|
||||||
|
{"Schedule", d.schedule},
|
||||||
|
{"Command", d.command},
|
||||||
|
{"Arguments", d.arguments},
|
||||||
|
{"Run mode", d.runMode},
|
||||||
|
{"Overlap policy", d.overlapPolicy},
|
||||||
|
{"Timeout", d.timeout},
|
||||||
|
{"State", d.state},
|
||||||
|
{"Last run", d.lastRun},
|
||||||
|
{"Next run", d.nextRun},
|
||||||
|
{"Statistics", d.stats},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// container assembles the details pane layout: metadata rows pin to the top,
|
// container assembles the details pane layout: metadata rows pin to the top,
|
||||||
// the activity panel pins to the bottom, and command output fills the remainder.
|
// the activity panel pins to the bottom, and command output fills the remainder.
|
||||||
func (d *detailsPanel) container() fyne.CanvasObject {
|
func (d *detailsPanel) container() fyne.CanvasObject {
|
||||||
// Metadata is laid out in two columns so the block stays half as tall,
|
// Metadata is laid out in two columns so the block stays half as tall,
|
||||||
// keeping the details pane usable on 720p screens where a single column of
|
// keeping the details pane usable on 720p screens where a single column of
|
||||||
// ten rows pushes the minimum window height past the available space.
|
// ten rows pushes the minimum window height past the available space.
|
||||||
capW := detailCaptionWidth()
|
specs := d.metadataRows()
|
||||||
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
|
captions := make([]string, len(specs))
|
||||||
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
for i, spec := range specs {
|
||||||
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
captions[i] = spec.caption
|
||||||
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
}
|
||||||
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
|
capW := captionColumnWidth(captions...)
|
||||||
detailRowPair(capW, "State", d.state, "Statistics", d.stats),
|
rowObjects := make([]fyne.CanvasObject, 0, (len(specs)+1)/2)
|
||||||
)
|
for i := 0; i+1 < len(specs); i += 2 {
|
||||||
|
rowObjects = append(rowObjects, detailRowPair(capW, specs[i].caption, specs[i].value, specs[i+1].caption, specs[i+1].value))
|
||||||
|
}
|
||||||
|
// An odd row count leaves one caption without a partner (Statistics, today);
|
||||||
|
// it falls through to a single-column row rather than being paired with
|
||||||
|
// nothing.
|
||||||
|
if len(specs)%2 == 1 {
|
||||||
|
last := specs[len(specs)-1]
|
||||||
|
rowObjects = append(rowObjects, detailRow(capW, last.caption, last.value))
|
||||||
|
}
|
||||||
|
rows := container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), rowObjects...)
|
||||||
top := container.NewVBox(
|
top := container.NewVBox(
|
||||||
d.title,
|
d.title,
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
@@ -147,30 +197,12 @@ func (d *detailsPanel) container() fyne.CanvasObject {
|
|||||||
// absorbs sub-pixel rounding so the last row is never clipped behind a scrollbar.
|
// absorbs sub-pixel rounding so the last row is never clipped behind a scrollbar.
|
||||||
func activityRowsHeight(rows int) float32 {
|
func activityRowsHeight(rows int) float32 {
|
||||||
sample := widget.NewLabel("log")
|
sample := widget.NewLabel("log")
|
||||||
sample.Wrapping = fyne.TextTruncate
|
sample.Truncation = fyne.TextTruncateClip
|
||||||
itemHeight := sample.MinSize().Height
|
itemHeight := sample.MinSize().Height
|
||||||
padding := theme.Padding()
|
padding := theme.Padding()
|
||||||
return (itemHeight+padding)*float32(rows) - padding + 1
|
return (itemHeight+padding)*float32(rows) - padding + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailCaptionWidth returns the width reserved for every metadata caption,
|
|
||||||
// derived from the widest caption label so the value columns all start at the
|
|
||||||
// same x and no caption truncates. Measuring a real label keeps it DPI- and
|
|
||||||
// theme-aware instead of relying on a hand-tuned constant.
|
|
||||||
func detailCaptionWidth() float32 {
|
|
||||||
captions := []string{
|
|
||||||
"Folder", "Schedule", "Command", "Arguments", "Run mode",
|
|
||||||
"Overlap policy", "Last run", "Next run", "State", "Statistics",
|
|
||||||
}
|
|
||||||
var width float32
|
|
||||||
for _, c := range captions {
|
|
||||||
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
|
|
||||||
width = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
// detailRowPair places two label/value pairs side by side, producing the
|
// detailRowPair places two label/value pairs side by side, producing the
|
||||||
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
||||||
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
||||||
@@ -179,7 +211,7 @@ func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 str
|
|||||||
|
|
||||||
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
caption.Wrapping = fyne.TextTruncate
|
caption.Truncation = fyne.TextTruncateClip
|
||||||
// A fixed caption width (rather than an even split) means widening the window
|
// A fixed caption width (rather than an even split) means widening the window
|
||||||
// feeds the extra space to the value, not the short caption.
|
// feeds the extra space to the value, not the short caption.
|
||||||
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
|
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
|
||||||
@@ -187,6 +219,6 @@ func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne
|
|||||||
|
|
||||||
func newJobDetailLabel(text string) *widget.Label {
|
func newJobDetailLabel(text string) *widget.Label {
|
||||||
label := widget.NewLabel(text)
|
label := widget.NewLabel(text)
|
||||||
label.Wrapping = fyne.TextTruncate
|
label.Truncation = fyne.TextTruncateClip
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -47,11 +51,31 @@ func filterValue(folder string) string {
|
|||||||
return strings.TrimSpace(folder)
|
return strings.TrimSpace(folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextJobListView returns the mode the view toggle switches to. Anything that
|
||||||
|
// is not compact reads as detailed, so unknown and legacy values flip to
|
||||||
|
// compact just as an explicit "detailed" does.
|
||||||
|
func nextJobListView(current domain.JobListView) domain.JobListView {
|
||||||
|
if current.IsCompact() {
|
||||||
|
return domain.JobListViewDetailed
|
||||||
|
}
|
||||||
|
return domain.JobListViewCompact
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewToggleText labels the view toggle with the action it performs, not the
|
||||||
|
// current state — the same convention as the "Disable auto"/"Enable auto"
|
||||||
|
// button. It also keeps the on-disk strings away from the user.
|
||||||
|
func viewToggleText(current domain.JobListView) string {
|
||||||
|
if current.IsCompact() {
|
||||||
|
return "Detailed"
|
||||||
|
}
|
||||||
|
return "Compact"
|
||||||
|
}
|
||||||
|
|
||||||
func indexOfID(jobs []job, id int) int {
|
func indexOfID(jobs []job, id int) int {
|
||||||
for index, current := range jobs {
|
for index, current := range jobs {
|
||||||
if current.ID == id {
|
if current.ID == id {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +1,18 @@
|
|||||||
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/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFilterValue(t *testing.T) {
|
func TestFilterValue(t *testing.T) {
|
||||||
@@ -30,8 +39,8 @@ func TestFolderOptionsAlwaysIncludesSentinels(t *testing.T) {
|
|||||||
func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
||||||
jobs := []domain.Job{
|
jobs := []domain.Job{
|
||||||
{Folder: "Maintenance"},
|
{Folder: "Maintenance"},
|
||||||
{Folder: ""}, // no folder → not a named folder
|
{Folder: ""}, // no folder → not a named folder
|
||||||
{Folder: " Backups "}, // trimmed to "Backups"
|
{Folder: " Backups "}, // trimmed to "Backups"
|
||||||
{Folder: "Maintenance"}, // duplicate → not added again
|
{Folder: "Maintenance"}, // duplicate → not added again
|
||||||
}
|
}
|
||||||
opts := folderOptions(jobs)
|
opts := folderOptions(jobs)
|
||||||
@@ -50,46 +59,499 @@ func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilteredJobIndexesAll(t *testing.T) {
|
func TestFilteredJobIndexes(t *testing.T) {
|
||||||
jobs := []domain.Job{
|
|
||||||
{Folder: "Maintenance"},
|
|
||||||
{Folder: ""},
|
|
||||||
{Folder: "Reports"},
|
|
||||||
}
|
|
||||||
got := filteredJobIndexes(jobs, allFolders)
|
|
||||||
if len(got) != 3 {
|
|
||||||
t.Errorf("allFolders filter: got %d indexes, want 3", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilteredJobIndexesByNamedFolder(t *testing.T) {
|
|
||||||
jobs := []domain.Job{
|
jobs := []domain.Job{
|
||||||
{Folder: "Maintenance"}, // index 0
|
{Folder: "Maintenance"}, // index 0
|
||||||
{Folder: ""}, // index 1
|
{Folder: ""}, // index 1 — no folder
|
||||||
{Folder: "Maintenance"}, // index 2
|
{Folder: "Maintenance"}, // index 2
|
||||||
{Folder: "Reports"}, // index 3
|
{Folder: "Reports"}, // index 3
|
||||||
|
{Folder: " "}, // index 4 — blank reads as no folder
|
||||||
}
|
}
|
||||||
got := filteredJobIndexes(jobs, "Maintenance")
|
cases := []struct {
|
||||||
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
|
name string
|
||||||
t.Errorf("Maintenance filter: got %v, want [0 2]", got)
|
jobs []domain.Job
|
||||||
|
filter string
|
||||||
|
want []int
|
||||||
|
}{
|
||||||
|
{"all folders", jobs, allFolders, []int{0, 1, 2, 3, 4}},
|
||||||
|
{"named folder", jobs, "Maintenance", []int{0, 2}},
|
||||||
|
{"no folder", jobs, noFolder, []int{1, 4}},
|
||||||
|
{"empty job list", nil, allFolders, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := filteredJobIndexes(tc.jobs, tc.filter)
|
||||||
|
if !sameIndexes(got, tc.want) {
|
||||||
|
t.Errorf("%s: filteredJobIndexes(_, %q) = %v, want %v", tc.name, tc.filter, got, tc.want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilteredJobIndexesNoFolder(t *testing.T) {
|
func sameIndexes(got, want []int) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextJobListViewFlipsBothWays(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
current, want domain.JobListView
|
||||||
|
}{
|
||||||
|
{domain.JobListViewDetailed, domain.JobListViewCompact},
|
||||||
|
{domain.JobListViewCompact, domain.JobListViewDetailed},
|
||||||
|
// Empty and unknown values read as detailed, so they flip to compact.
|
||||||
|
{"", domain.JobListViewCompact},
|
||||||
|
{"tiny", domain.JobListViewCompact},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := nextJobListView(tc.current); got != tc.want {
|
||||||
|
t.Errorf("nextJobListView(%q) = %q, want %q", tc.current, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFirst walks a widget tree depth-first and returns the first object the
|
||||||
|
// match function accepts. Tests use it to reach widgets newJobsView builds
|
||||||
|
// internally rather than returning.
|
||||||
|
func findFirst(root fyne.CanvasObject, match func(fyne.CanvasObject) bool) fyne.CanvasObject {
|
||||||
|
if match(root) {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
container, ok := root.(*fyne.Container)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, child := range container.Objects {
|
||||||
|
if found := findFirst(child, match); found != nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsSplit returns the view's master/detail split. newJobsView assembles the
|
||||||
|
// panel as container.NewHSplit(sidebar, details), so the two panes are reached
|
||||||
|
// through Leading and Trailing.
|
||||||
|
func jobsSplit(t *testing.T, content fyne.CanvasObject) *container.Split {
|
||||||
|
t.Helper()
|
||||||
|
split, ok := content.(*container.Split)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("jobs view is not the expected Split container")
|
||||||
|
}
|
||||||
|
return split
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsSidebar narrows the search to the left pane. The details panel has a
|
||||||
|
// widget.List of its own (the activity log), so a search from the whole view
|
||||||
|
// would find the wrong one.
|
||||||
|
func jobsSidebar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
t.Helper()
|
||||||
|
return jobsSplit(t, content).Leading
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
_, ok := o.(*widget.List)
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("jobs sidebar contains no list widget")
|
||||||
|
}
|
||||||
|
return found.(*widget.List)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsToolbar finds the add/edit/run/pause/delete button row inside the
|
||||||
|
// sidebar, identified by its first child being the "New job" button.
|
||||||
|
func jobsToolbar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
wrapper, ok := o.(*fyne.Container)
|
||||||
|
if !ok || len(wrapper.Objects) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
button, ok := wrapper.Objects[0].(*widget.Button)
|
||||||
|
return ok && button.Text == "New job"
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("jobs sidebar has no toolbar row")
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsToolbarButton returns the toolbar button with the given caption.
|
||||||
|
func jobsToolbarButton(t *testing.T, content fyne.CanvasObject, text string) *widget.Button {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsToolbar(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
button, ok := o.(*widget.Button)
|
||||||
|
return ok && button.Text == text
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("jobs toolbar has no %q button", text)
|
||||||
|
}
|
||||||
|
return found.(*widget.Button)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsDetails narrows the search to the right pane (see jobsSidebar).
|
||||||
|
func jobsDetails(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
t.Helper()
|
||||||
|
return jobsSplit(t, content).Trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsDetailsActivity returns the "Selected job activity" list, the only
|
||||||
|
// widget.List in the details pane.
|
||||||
|
func jobsDetailsActivity(t *testing.T, content fyne.CanvasObject) *widget.List {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsDetails(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
_, ok := o.(*widget.List)
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("details pane has no activity list")
|
||||||
|
}
|
||||||
|
return found.(*widget.List)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsDetailsTitle reads the details pane's heading, which detailsPanel builds
|
||||||
|
// as the first bold label in the pane.
|
||||||
|
func jobsDetailsTitle(t *testing.T, content fyne.CanvasObject) string {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsDetails(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
label, ok := o.(*widget.Label)
|
||||||
|
return ok && label.TextStyle.Bold
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("details pane has no title label")
|
||||||
|
}
|
||||||
|
return found.(*widget.Label).Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobsViewToggle(t *testing.T, content fyne.CanvasObject) *widget.Button {
|
||||||
|
t.Helper()
|
||||||
|
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
|
||||||
|
button, ok := o.(*widget.Button)
|
||||||
|
return ok && (button.Text == "Compact" || button.Text == "Detailed")
|
||||||
|
})
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("jobs sidebar has no view toggle button")
|
||||||
|
}
|
||||||
|
return found.(*widget.Button)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobListViewToggleShrinksRowsAndPersists is the end-to-end guard for the
|
||||||
|
// compact view: one tap must shrink the list rows, relabel the button with the
|
||||||
|
// opposite action, and reach the config — and toggling back must undo all
|
||||||
|
// three. Row height is measured through List.CreateItem/UpdateItem because
|
||||||
|
// that is exactly what widget.List caches as the row height.
|
||||||
|
func TestJobListViewToggleShrinksRowsAndPersists(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
jobs := []domain.Job{
|
jobs := []domain.Job{
|
||||||
{Folder: "Maintenance"}, // index 0 — excluded
|
{ID: 1, Name: "Nightly backup", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo hi", Enabled: true},
|
||||||
{Folder: ""}, // index 1 — no folder → included
|
|
||||||
{Folder: " "}, // index 2 — blank → included
|
|
||||||
}
|
}
|
||||||
got := filteredJobIndexes(jobs, noFolder)
|
svc := app.NewService(store, jobs)
|
||||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
defer svc.Stop()
|
||||||
t.Errorf("noFolder filter: got %v, want [1 2]", got)
|
|
||||||
|
content, _ := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
list := jobsList(t, content)
|
||||||
|
viewButton := jobsViewToggle(t, content)
|
||||||
|
if viewButton.Text != "Compact" {
|
||||||
|
t.Fatalf("a default config should open detailed: button text = %q, want %q", viewButton.Text, "Compact")
|
||||||
|
}
|
||||||
|
|
||||||
|
rowHeight := func() float32 {
|
||||||
|
t.Helper()
|
||||||
|
row := list.CreateItem()
|
||||||
|
list.UpdateItem(0, row)
|
||||||
|
return row.MinSize().Height
|
||||||
|
}
|
||||||
|
detailedHeight := rowHeight()
|
||||||
|
|
||||||
|
test.Tap(viewButton)
|
||||||
|
if store.Config.JobListView != domain.JobListViewCompact {
|
||||||
|
t.Errorf("after tapping, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewCompact)
|
||||||
|
}
|
||||||
|
if viewButton.Text != "Detailed" {
|
||||||
|
t.Errorf("after tapping, button text = %q, want %q", viewButton.Text, "Detailed")
|
||||||
|
}
|
||||||
|
compactHeight := rowHeight()
|
||||||
|
if compactHeight >= detailedHeight {
|
||||||
|
t.Errorf("compact row height = %v, want less than detailed %v", compactHeight, detailedHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Tap(viewButton)
|
||||||
|
if store.Config.JobListView != domain.JobListViewDetailed {
|
||||||
|
t.Errorf("after tapping back, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewDetailed)
|
||||||
|
}
|
||||||
|
if viewButton.Text != "Compact" {
|
||||||
|
t.Errorf("after tapping back, button text = %q, want %q", viewButton.Text, "Compact")
|
||||||
|
}
|
||||||
|
if got := rowHeight(); got != detailedHeight {
|
||||||
|
t.Errorf("row height after switching back = %v, want the original %v", got, detailedHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilteredJobIndexesEmptySlice(t *testing.T) {
|
// TestJobListViewCompactConfigOpensCompact checks the persisted preference is
|
||||||
got := filteredJobIndexes(nil, allFolders)
|
// honoured at build time, not just after a tap.
|
||||||
if len(got) != 0 {
|
func TestJobListViewCompactConfigOpensCompact(t *testing.T) {
|
||||||
t.Errorf("empty job list should return empty indexes, got %v", got)
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
store.Config.JobListView = domain.JobListViewCompact
|
||||||
|
svc := app.NewService(store, nil)
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, _ := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
if got := jobsViewToggle(t, content).Text; got != "Detailed" {
|
||||||
|
t.Errorf("button text for a compact config = %q, want %q", got, "Detailed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsSidebarWidthIsItsContent is the regression guard for F7: nothing
|
||||||
|
// but the sidebar's own content (here, the toolbar row) should impose a
|
||||||
|
// width floor on it.
|
||||||
|
func TestJobsSidebarWidthIsItsContent(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
svc := app.NewService(store, nil)
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, _ := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
sidebarWidth := jobsSidebar(t, content).MinSize().Width
|
||||||
|
toolbarWidth := jobsToolbar(t, content).MinSize().Width
|
||||||
|
if sidebarWidth != toolbarWidth {
|
||||||
|
t.Errorf("sidebar MinSize().Width = %v, want it to equal the toolbar row's %v", sidebarWidth, toolbarWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsSplitOpensAtTheSidebarWidth is the guard for the derived initial
|
||||||
|
// offset (F15): at the default window width the divider must open at the
|
||||||
|
// sidebar's own width — enough that the toolbar is never born clipped, and no
|
||||||
|
// more, since every extra pixel is taken from the details pane. Split's own
|
||||||
|
// clamp guarantees the lower bound, so the upper bound is what actually proves
|
||||||
|
// the offset was derived rather than left at the 0.5 default.
|
||||||
|
func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
svc := app.NewService(store, nil)
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, _ := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
split := jobsSplit(t, content)
|
||||||
|
split.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||||
|
|
||||||
|
want := split.Leading.MinSize().Width
|
||||||
|
got := split.Leading.Size().Width
|
||||||
|
// One pixel of slack for the float32 round trip through the offset ratio.
|
||||||
|
if got < want || got > want+1 {
|
||||||
|
t.Errorf("leading pane opens at %v, want its content minimum %v", got, want)
|
||||||
|
}
|
||||||
|
if trailing := split.Trailing.Size().Width; trailing < split.Trailing.MinSize().Width {
|
||||||
|
t.Errorf("trailing pane opens at %v, below its minimum %v", trailing, split.Trailing.MinSize().Width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
||||||
|
// toolbar handlers no longer re-read the service or refresh the list
|
||||||
|
// themselves, so jobsView.refresh alone has to re-snapshot the jobs and repopulate
|
||||||
|
// the details pane. If it ever stops doing either, the row renders a stale
|
||||||
|
// status and the details lose the selection — neither is a compile error.
|
||||||
|
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
jobs := []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},
|
||||||
|
}
|
||||||
|
svc := app.NewService(store, jobs)
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, _ := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
list := jobsList(t, content)
|
||||||
|
// Row layout: VBox(nameLine, meta, status), nameLine = Border(name, inlineStatus).
|
||||||
|
rowText := func(id int) (name string, status string) {
|
||||||
|
t.Helper()
|
||||||
|
row := list.CreateItem().(*fyne.Container)
|
||||||
|
list.UpdateItem(id, row)
|
||||||
|
nameLine := row.Objects[0].(*fyne.Container)
|
||||||
|
return nameLine.Objects[0].(*widget.Label).Text, row.Objects[2].(*widget.Label).Text
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := jobsDetailsActivity(t, content)
|
||||||
|
|
||||||
|
list.Select(1)
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Second" {
|
||||||
|
t.Fatalf("details title after selecting row 1 = %q, want %q", got, "Second")
|
||||||
|
}
|
||||||
|
if _, status := rowText(1); status == "Paused" {
|
||||||
|
t.Fatal("the second job should start enabled")
|
||||||
|
}
|
||||||
|
if got := activity.Length(); got != 0 {
|
||||||
|
t.Fatalf("activity rows before the tap = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Tap(jobsToolbarButton(t, content, "Pause"))
|
||||||
|
|
||||||
|
if svc.Jobs()[1].Enabled {
|
||||||
|
t.Fatal("tapping Pause did not reach the service")
|
||||||
|
}
|
||||||
|
name, status := rowText(1)
|
||||||
|
if name != "Second" || status != "Paused" {
|
||||||
|
t.Errorf("row 1 after Pause = (%q, %q), want (%q, %q)", name, status, "Second", "Paused")
|
||||||
|
}
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Second" {
|
||||||
|
t.Errorf("details title after Pause = %q, want the selection kept at %q", got, "Second")
|
||||||
|
}
|
||||||
|
// The pause writes an activity record. Seeing it here is what proves
|
||||||
|
// refreshView repopulated the details pane rather than leaving the panel on
|
||||||
|
// the snapshot it held before the tap.
|
||||||
|
if got := activity.Length(); got != 1 {
|
||||||
|
t.Errorf("activity rows after the tap = %d, want the pause record", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewSelectionSurvivesAJobsFileSwitch is the view-level regression
|
||||||
|
// guard for the selection defect. Adopting a different jobs file replaces the
|
||||||
|
// whole list from the Service; the view only hears about it through the refresh
|
||||||
|
// that JobsLoaded triggers, which is exactly what this test calls. With the
|
||||||
|
// selection held as a row index, that refresh redrew the details pane from the
|
||||||
|
// old index — describing whichever job now sat there, or clearing the pane when
|
||||||
|
// the new list was shorter — while the list's highlight stayed where it was.
|
||||||
|
func TestJobsViewSelectionSurvivesAJobsFileSwitch(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
svc := app.NewService(store, []domain.Job{
|
||||||
|
{ID: 1, Name: "First", Schedule: "@every 1m", Command: "echo one", Enabled: true},
|
||||||
|
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
|
||||||
|
{ID: 3, Name: "Third", Schedule: "@every 3m", Command: "echo three", Enabled: true},
|
||||||
|
})
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, refresh := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
list := jobsList(t, content)
|
||||||
|
list.Select(2)
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Third" {
|
||||||
|
t.Fatalf("details title after selecting row 2 = %q, want %q", got, "Third")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second jobs file with different jobs and different IDs, so nothing about
|
||||||
|
// the old selection can resolve into the new list.
|
||||||
|
other := []domain.Job{
|
||||||
|
{ID: 10, Name: "Alpha", Schedule: "@every 1m", Command: "echo alpha", Enabled: true},
|
||||||
|
{ID: 11, Name: "Beta", Schedule: "@every 2m", Command: "echo beta", Enabled: true},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(domain.JobsFile{Jobs: other})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal jobs: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.Paths.AppDir, "other.json"), payload, 0o644); err != nil {
|
||||||
|
t.Fatalf("write jobs file: %v", err)
|
||||||
|
}
|
||||||
|
config := svc.Config()
|
||||||
|
config.JobsFile = "other.json"
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Alpha" {
|
||||||
|
t.Errorf("details title after the switch = %q, want the first job of the new file %q", got, "Alpha")
|
||||||
|
}
|
||||||
|
if got := list.Length(); got != len(other) {
|
||||||
|
t.Fatalf("list length after the switch = %d, want %d", got, len(other))
|
||||||
|
}
|
||||||
|
// widget.List.Select returns without calling OnSelected when the row is
|
||||||
|
// already highlighted, so a silent Select(0) is what proves the highlight and
|
||||||
|
// the details pane are describing the same job.
|
||||||
|
reselected := false
|
||||||
|
inner := list.OnSelected
|
||||||
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
|
reselected = true
|
||||||
|
inner(id)
|
||||||
|
}
|
||||||
|
defer func() { list.OnSelected = inner }()
|
||||||
|
list.Select(0)
|
||||||
|
if reselected {
|
||||||
|
t.Error("row 0 was not the highlighted row after the switch, so the highlight and the details pane disagree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
|
||||||
|
// metadataRows list self-enforcing (F10): every caption it returns must
|
||||||
|
// measure no wider than captionColumnWidth's result for that same list, or a
|
||||||
|
// row added to metadataRows without updating the width measurement would
|
||||||
|
// silently truncate.
|
||||||
|
func TestDetailCaptionWidthCoversEveryCaption(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
|
||||||
|
d := newDetailsPanel(job{}, &domain.JobRuntime{}, domain.OverlapPolicySkip, 0)
|
||||||
|
specs := d.metadataRows()
|
||||||
|
captions := make([]string, len(specs))
|
||||||
|
for i, spec := range specs {
|
||||||
|
captions[i] = spec.caption
|
||||||
|
}
|
||||||
|
capW := captionColumnWidth(captions...)
|
||||||
|
for _, c := range captions {
|
||||||
|
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > capW {
|
||||||
|
t.Errorf("caption %q measures %v, wider than captionColumnWidth's %v", c, w, capW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewToggleTextNamesTheAction(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
current domain.JobListView
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{domain.JobListViewDetailed, "Compact"},
|
||||||
|
{domain.JobListViewCompact, "Detailed"},
|
||||||
|
{"", "Compact"},
|
||||||
|
{"tiny", "Compact"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := viewToggleText(tc.current); got != tc.want {
|
||||||
|
t.Errorf("viewToggleText(%q) = %q, want %q", tc.current, got, tc.want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,9 +2,25 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// captionColumnWidth returns the width to reserve for a column of bold
|
||||||
|
// captions: the widest of them, measured under the current theme so it tracks
|
||||||
|
// text size and DPI instead of a hand-tuned constant.
|
||||||
|
func captionColumnWidth(captions ...string) float32 {
|
||||||
|
var width float32
|
||||||
|
for _, caption := range captions {
|
||||||
|
if w := widget.NewLabelWithStyle(caption, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
|
||||||
|
width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
type minWidthLayout struct {
|
type minWidthLayout struct {
|
||||||
width float32
|
width float32
|
||||||
}
|
}
|
||||||
@@ -37,46 +53,42 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compactVBoxLayout stacks children vertically with a configurable gap between
|
// rowOverlap is the (negative) gap that pulls stacked label rows together by
|
||||||
// them, producing tighter rows than container.NewVBox (which inserts
|
// exactly one label's vertical inner padding. Two adjacent labels each inset
|
||||||
// theme.Padding() between every child). A negative spacing pulls neighbouring
|
// their text by theme.InnerPadding(), so the whitespace between two lines of
|
||||||
// rows together so they overlap the labels' built-in vertical padding, which is
|
// text is double what a single row needs; removing one label's worth
|
||||||
// how the details metadata is condensed to fit 720p screens.
|
// condenses the block without letting the text lines touch. Derived rather
|
||||||
type compactVBoxLayout struct {
|
// than hard-coded so it follows a theme that changes SizeNameInnerPadding.
|
||||||
spacing float32
|
func rowOverlap() float32 { return -theme.InnerPadding() }
|
||||||
|
|
||||||
|
// cancelRowOverlap exempts one row from the rowOverlap() spacing of the section
|
||||||
|
// it sits in, by padding its top edge with exactly what rowOverlap takes away.
|
||||||
|
// The overlap assumes both neighbours are text rows: each insets its text, so
|
||||||
|
// one padding's worth is duplicated and can go. A row whose value paints its own
|
||||||
|
// box to the row's edge — a Select, an Entry, a Button — has no such inset, so
|
||||||
|
// the overlap eats the visible gap instead and the box ends up flush against the
|
||||||
|
// row above it.
|
||||||
|
func cancelRowOverlap(row fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
return container.New(layout.NewCustomPaddedLayout(-rowOverlap(), 0, 0, 0), row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l compactVBoxLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
// initialSplitOffset returns the container.Split offset that opens a horizontal
|
||||||
var w, h float32
|
// split with its leading pane at the given natural width. SetOffset takes a
|
||||||
var visible int
|
// ratio, but a pane's natural width is absolute, so the ratio is derived from
|
||||||
for _, o := range objects {
|
// the window width the app opens at rather than written as a literal: 0.44 fits
|
||||||
if !o.Visible() {
|
// 1024 px but would hand a 448 px sidebar 700 px on a 1600 px-wide window.
|
||||||
continue
|
//
|
||||||
}
|
// The divider sits between the panes and is excluded from the ratio, matching
|
||||||
min := o.MinSize()
|
// container.Split's own arithmetic (its divider is two theme paddings thick).
|
||||||
if min.Width > w {
|
// Split clamps the offset to both panes' minimums when it lays out, so a result
|
||||||
w = min.Width
|
// that is slightly off — the window is a little wider than its content area —
|
||||||
}
|
// costs at most a few pixels and can never clip either pane.
|
||||||
h += min.Height
|
func initialSplitOffset(leadingWidth float32) float64 {
|
||||||
visible++
|
available := float64(defaultWindowWidth - 2*theme.Padding())
|
||||||
}
|
if available <= 0 {
|
||||||
if visible > 1 {
|
return 0
|
||||||
h += l.spacing * float32(visible-1)
|
|
||||||
}
|
|
||||||
return fyne.NewSize(w, h)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l compactVBoxLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
|
||||||
var y float32
|
|
||||||
for _, o := range objects {
|
|
||||||
if !o.Visible() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h := o.MinSize().Height
|
|
||||||
o.Move(fyne.NewPos(0, y))
|
|
||||||
o.Resize(fyne.NewSize(size.Width, h))
|
|
||||||
y += h + l.spacing
|
|
||||||
}
|
}
|
||||||
|
return float64(leadingWidth) / available
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixedHeightLayout forces its contents to a fixed height while leaving the
|
// fixedHeightLayout forces its contents to a fixed height while leaving the
|
||||||
@@ -114,10 +126,21 @@ func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
|||||||
// stops it from growing with the window (as an even two-column grid would), so
|
// stops it from growing with the window (as an even two-column grid would), so
|
||||||
// the extra space a wider window provides goes entirely to the value column. It
|
// the extra space a wider window provides goes entirely to the value column. It
|
||||||
// expects exactly two children: caption first, value second.
|
// expects exactly two children: caption first, value second.
|
||||||
|
//
|
||||||
|
// The value column has no minimum of its own: it gets whatever the container's
|
||||||
|
// width leaves, down to zero. What keeps it readable in the details pane is the
|
||||||
|
// 460 px minimum on commandOutputScroll (jobs_view_details.go), which is that
|
||||||
|
// pane's widest minimum and therefore its floor — a constant that exists for an
|
||||||
|
// unrelated reason. A new caller that gives this layout less width gets a value
|
||||||
|
// column that silently renders narrow or empty rather than one that clips.
|
||||||
type captionValueLayout struct {
|
type captionValueLayout struct {
|
||||||
captionWidth float32
|
captionWidth float32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinSize and Layout both return silently when given anything but two
|
||||||
|
// objects. That is acceptable here because the type is package-private with a
|
||||||
|
// single constructor (detailRow), which always supplies exactly a caption and
|
||||||
|
// a value — there is no external caller that could pass the wrong count.
|
||||||
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
if len(objects) != 2 {
|
if len(objects) != 2 {
|
||||||
return fyne.Size{}
|
return fyne.Size{}
|
||||||
|
|||||||