Stabilize packaging and scheduler storage
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
bin
|
||||||
|
dist
|
||||||
|
logs
|
||||||
|
pysentry.yaml
|
||||||
|
jobs.yaml
|
||||||
|
*.exe
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# ---> Python
|
# ---> Python
|
||||||
# ---> Go
|
# ---> Go
|
||||||
bin/
|
bin/
|
||||||
|
dist/
|
||||||
|
cmd/pysentry/*.syso
|
||||||
*.exe
|
*.exe
|
||||||
|
pysentry.yaml
|
||||||
|
jobs.yaml
|
||||||
|
logs/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM golang:1.22-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
gcc \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
xorg-dev && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=1
|
||||||
|
ENV GOOS=linux
|
||||||
|
ENV GOARCH=amd64
|
||||||
|
|
||||||
|
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry
|
||||||
@@ -1,37 +1,173 @@
|
|||||||
# PySentry
|
# PySentry
|
||||||
|
|
||||||
PySentry is a cross-platform desktop scheduler inspired by cron.
|
PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
||||||
|
|
||||||
The project is starting with the GUI shell first, then the scheduling core.
|
## Features
|
||||||
|
|
||||||
|
- Native desktop GUI built with Fyne.
|
||||||
|
- Job storage in one clean YAML file.
|
||||||
|
- App settings in a separate YAML file.
|
||||||
|
- `@every` schedules and standard 5-field cron expressions.
|
||||||
|
- Manual and scheduled command runs.
|
||||||
|
- Per-run `.log` files with stdout/stderr.
|
||||||
|
- Log cleanup by maximum file count and maximum age.
|
||||||
|
- Global pause/resume for all job execution.
|
||||||
|
- Windows tray support.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.22 or newer
|
Common:
|
||||||
- A C compiler for Fyne builds on Windows, for example MSYS2/MinGW-w64
|
|
||||||
|
|
||||||
## Run
|
- Go 1.22 or newer.
|
||||||
|
|
||||||
```powershell
|
Windows:
|
||||||
go mod tidy
|
|
||||||
go run ./cmd/pysentry
|
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
- A C compiler.
|
||||||
|
- Fyne native build dependencies, including OpenGL/X11 development packages.
|
||||||
|
|
||||||
|
On Debian/Ubuntu, the Linux dependencies are typically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
If Go is installed but not available in `PATH`, use the full path:
|
## Build
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
.\scripts\build-windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist\windows\pysentry.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x ./scripts/build-linux.sh
|
||||||
|
./scripts/build-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/linux/pysentry
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux using Docker:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\build-linux-docker.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist\linux\pysentry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run From Source
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
|
||||||
|
$env:CGO_ENABLED = '1'
|
||||||
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
|
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Current shape
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 go run ./cmd/pysentry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
PySentry creates its runtime files next to the executable by default.
|
||||||
|
|
||||||
|
`pysentry.yaml` stores application settings:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs_dir: .
|
||||||
|
logs_dir: logs
|
||||||
|
max_log_files: 100
|
||||||
|
max_log_age_days: 30
|
||||||
|
keep_running_in_tray: true
|
||||||
|
notify_on_failure: true
|
||||||
|
```
|
||||||
|
|
||||||
|
`jobs.yaml` stores only job definitions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
- id: 1
|
||||||
|
name: Hello scheduler
|
||||||
|
folder: Examples
|
||||||
|
schedule: '@every 10s'
|
||||||
|
command: echo PySentry test job: scheduler is alive
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
20260614-224306_Hello_scheduler.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schedules
|
||||||
|
|
||||||
|
Fast interval schedules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@every 10s
|
||||||
|
@every 5m
|
||||||
|
@every 1h30m
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard 5-field cron schedules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
*/5 * * * * every five minutes
|
||||||
|
0 2 * * * every day at 02:00
|
||||||
|
30 9 * * 1-5 weekdays at 09:30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using The App
|
||||||
|
|
||||||
|
1. Start PySentry.
|
||||||
|
2. Use `New job` to create a command.
|
||||||
|
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
|
||||||
|
4. Use `Run now` for a manual test run.
|
||||||
|
5. Use `Pause` to disable one job.
|
||||||
|
6. Use `Pause all` as a global stop switch.
|
||||||
|
7. Open `History` to see whether a run was `Manual`, `Schedule`, or `UI`.
|
||||||
|
8. Open `Settings` to change `jobs_dir`, `logs_dir`, and log cleanup limits. Use `Browse` to choose directories.
|
||||||
|
|
||||||
|
Changing `jobs_dir` saves the current job list to the new directory.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
- `cmd/pysentry` starts the desktop app.
|
- `cmd/pysentry` starts the desktop app.
|
||||||
- `internal/app` contains the first Fyne-based interface prototype.
|
- `internal/app` contains the GUI.
|
||||||
- `internal/core` contains YAML storage, command execution, and the first scheduler loop.
|
- `internal/core` contains YAML storage, command execution, scheduling, and log cleanup.
|
||||||
- Jobs can be created, edited, paused/resumed, run manually, and persisted to YAML.
|
- `assets` contains app icons.
|
||||||
- Settings are stored in `pysentry.yaml` next to the executable.
|
- `scripts` contains build helpers.
|
||||||
- Jobs are stored in one `jobs.yaml` file. The job directory is configured by `jobs_dir` and defaults to the executable directory.
|
|
||||||
- Command output is also written to per-run `.log` files in `logs_dir`. Log filenames include the run timestamp and job name.
|
## Dependencies
|
||||||
- Log cleanup is controlled by `max_log_files` and `max_log_age_days`.
|
|
||||||
- The current scheduler supports `@every` schedules such as `@every 10s` and `@every 1m`.
|
PySentry keeps the direct dependency list intentionally small:
|
||||||
- Run history records include a `trigger` value such as `Manual`, `Schedule`, or `UI`.
|
|
||||||
- Cron expression parsing is planned for the next phase.
|
- `fyne.io/fyne/v2` for the native GUI.
|
||||||
|
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
||||||
|
- `gopkg.in/yaml.v3` for YAML settings and jobs.
|
||||||
|
|
||||||
|
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 996 KiB |
@@ -4,6 +4,7 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.5.3
|
fyne.io/fyne/v2 v2.5.3
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
|||||||
+39
-20
@@ -1,9 +1,8 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +26,9 @@ const noFolder = "No folder"
|
|||||||
type job = core.Job
|
type job = core.Job
|
||||||
type event = core.RunRecord
|
type event = core.RunRecord
|
||||||
|
|
||||||
|
//go:embed assets/pysentry-icon.png
|
||||||
|
var iconBytes []byte
|
||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
a := app.NewWithID(appID)
|
a := app.NewWithID(appID)
|
||||||
a.SetIcon(loadAppIcon())
|
a.SetIcon(loadAppIcon())
|
||||||
@@ -39,19 +41,7 @@ func Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAppIcon() fyne.Resource {
|
func loadAppIcon() fyne.Resource {
|
||||||
candidates := []string{}
|
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
|
||||||
if executable, err := os.Executable(); err == nil {
|
|
||||||
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "pysentry-icon.png"))
|
|
||||||
}
|
|
||||||
if workingDir, err := os.Getwd(); err == nil {
|
|
||||||
candidates = append(candidates, filepath.Join(workingDir, "assets", "pysentry-icon.png"))
|
|
||||||
}
|
|
||||||
for _, path := range candidates {
|
|
||||||
if resource, err := fyne.LoadResourceFromPath(path); err == nil {
|
|
||||||
return resource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return theme.ComputerIcon()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||||
@@ -379,7 +369,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
|
||||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(store)),
|
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
|
||||||
)
|
)
|
||||||
tabs.SetTabLocation(container.TabLocationTop)
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
@@ -557,14 +547,22 @@ func newHistoryView(events *[]event) *fyne.Container {
|
|||||||
return container.NewPadded(list)
|
return container.NewPadded(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(store *core.Store) fyne.CanvasObject {
|
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
||||||
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
|
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||||
|
jobsDir := widget.NewEntry()
|
||||||
|
jobsDir.SetText(store.Config.JobsDir)
|
||||||
|
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
|
chooseFolder(w, jobsDir)
|
||||||
|
})
|
||||||
logsDir := widget.NewEntry()
|
logsDir := widget.NewEntry()
|
||||||
logsDir.SetText(store.Config.LogsDir)
|
logsDir.SetText(store.Config.LogsDir)
|
||||||
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
|
chooseFolder(w, logsDir)
|
||||||
|
})
|
||||||
maxLogFiles := widget.NewEntry()
|
maxLogFiles := widget.NewEntry()
|
||||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||||
maxLogAgeDays := widget.NewEntry()
|
maxLogAgeDays := widget.NewEntry()
|
||||||
@@ -583,6 +581,15 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
|
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||||
|
if strings.TrimSpace(jobsDir.Text) == "" {
|
||||||
|
settingsStatus.SetText("Jobs directory is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(logsDir.Text) == "" {
|
||||||
|
settingsStatus.SetText("Logs directory is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
||||||
store.Config.MaxLogFiles = files
|
store.Config.MaxLogFiles = files
|
||||||
store.Config.MaxLogAgeDays = days
|
store.Config.MaxLogAgeDays = days
|
||||||
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
||||||
@@ -591,6 +598,10 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
|||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := store.SaveJobs(*jobs); err != nil {
|
||||||
|
settingsStatus.SetText("Jobs save failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
||||||
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
||||||
return
|
return
|
||||||
@@ -606,9 +617,8 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
|||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
||||||
detailRow("Jobs YAML", widget.NewLabel(store.Paths.JobsPath)),
|
detailRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||||
detailRow("Jobs directory", widget.NewLabel(store.Paths.JobsDir)),
|
detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||||
detailRow("Logs directory", logsDir),
|
|
||||||
detailRow("Max log files", maxLogFiles),
|
detailRow("Max log files", maxLogFiles),
|
||||||
detailRow("Max log age days", maxLogAgeDays),
|
detailRow("Max log age days", maxLogAgeDays),
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -618,3 +628,12 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
|||||||
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
|
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||||
|
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||||
|
if err != nil || uri == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.SetText(uri.Path())
|
||||||
|
}, w)
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 996 KiB |
@@ -22,11 +22,11 @@ type Job struct {
|
|||||||
Schedule string `yaml:"schedule"`
|
Schedule string `yaml:"schedule"`
|
||||||
Command string `yaml:"command"`
|
Command string `yaml:"command"`
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
LastRun string `yaml:"last_run,omitempty"`
|
LastRun string `yaml:"-"`
|
||||||
NextRun string `yaml:"next_run,omitempty"`
|
NextRun string `yaml:"-"`
|
||||||
LastState string `yaml:"last_state,omitempty"`
|
LastState string `yaml:"-"`
|
||||||
Logs []RunRecord `yaml:"activity,omitempty"`
|
Logs []RunRecord `yaml:"-"`
|
||||||
Output string `yaml:"last_output,omitempty"`
|
Output string `yaml:"-"`
|
||||||
|
|
||||||
nextDue time.Time
|
nextDue time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-10
@@ -5,8 +5,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *Store
|
store *Store
|
||||||
jobs *[]Job
|
jobs *[]Job
|
||||||
@@ -146,24 +150,31 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
|
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
|
||||||
interval, ok := parseEvery(job.Schedule)
|
next, ok := nextRunTime(job.Schedule, from)
|
||||||
if !ok {
|
if !ok {
|
||||||
job.NextRun = "Unsupported schedule"
|
job.NextRun = "Invalid schedule"
|
||||||
job.nextDue = time.Time{}
|
job.nextDue = time.Time{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.nextDue = from.Add(interval)
|
job.nextDue = next
|
||||||
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
|
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseEvery(schedule string) (time.Duration, bool) {
|
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
||||||
schedule = strings.TrimSpace(schedule)
|
schedule = strings.TrimSpace(schedule)
|
||||||
if !strings.HasPrefix(schedule, "@every ") {
|
if schedule == "" {
|
||||||
return 0, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
if strings.HasPrefix(schedule, "@every ") {
|
||||||
if err != nil || interval <= 0 {
|
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||||
return 0, false
|
if err != nil || interval <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return from.Add(interval), true
|
||||||
}
|
}
|
||||||
return interval, true
|
parsed, err := cronParser.Parse(schedule)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return parsed.Next(from), true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNextRunTimeSupportsEvery(t *testing.T) {
|
||||||
|
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
next, ok := nextRunTime("@every 10s", from)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected @every schedule to parse")
|
||||||
|
}
|
||||||
|
if want := from.Add(10 * time.Second); !next.Equal(want) {
|
||||||
|
t.Fatalf("expected %s, got %s", want, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRunTimeSupportsCron(t *testing.T) {
|
||||||
|
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
||||||
|
next, ok := nextRunTime("*/5 * * * *", from)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected cron schedule to parse")
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
||||||
|
if !next.Equal(want) {
|
||||||
|
t.Fatalf("expected %s, got %s", want, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-29
@@ -36,6 +36,10 @@ func OpenStore() (*Store, []Job, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
if err := store.SaveJobs(jobs); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
return store, jobs, nil
|
return store, jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
func loadOrCreateJobs(path string) ([]Job, error) {
|
func loadOrCreateJobs(path string) ([]Job, error) {
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
jobs := defaultJobs()
|
jobs := defaultJobs()
|
||||||
|
normalizeJobs(jobs)
|
||||||
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +112,42 @@ func loadOrCreateJobs(path string) ([]Job, error) {
|
|||||||
return file.Jobs, nil
|
return file.Jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []Job) {
|
||||||
|
next := 1
|
||||||
|
for index := range jobs {
|
||||||
|
job := &jobs[index]
|
||||||
|
if job.ID <= 0 {
|
||||||
|
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) == "" {
|
||||||
|
job.Command = echoCommand("PySentry job ran")
|
||||||
|
}
|
||||||
|
if job.LastRun == "" {
|
||||||
|
job.LastRun = "Never"
|
||||||
|
}
|
||||||
|
if job.Output == "" {
|
||||||
|
job.Output = "No command output captured yet."
|
||||||
|
}
|
||||||
|
if job.Enabled {
|
||||||
|
job.LastState = "Ready"
|
||||||
|
job.NextRun = "After start"
|
||||||
|
} else {
|
||||||
|
job.LastState = "Paused"
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
}
|
||||||
|
job.Logs = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||||
return resolveConfiguredDir(appDir, jobsDir)
|
return resolveConfiguredDir(appDir, jobsDir)
|
||||||
}
|
}
|
||||||
@@ -138,39 +179,27 @@ func writeYAML(path string, value any) error {
|
|||||||
func defaultJobs() []Job {
|
func defaultJobs() []Job {
|
||||||
return []Job{
|
return []Job{
|
||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Name: "Hello scheduler",
|
Name: "Hello scheduler",
|
||||||
Folder: "Examples",
|
Folder: "Examples",
|
||||||
Schedule: "@every 10s",
|
Schedule: "@every 10s",
|
||||||
Command: echoCommand("PySentry test job: scheduler is alive"),
|
Command: echoCommand("PySentry test job: scheduler is alive"),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
LastRun: "Never",
|
|
||||||
NextRun: "After start",
|
|
||||||
LastState: "Ready",
|
|
||||||
Output: "No command output captured yet.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Name: "Write timestamp",
|
Name: "Write timestamp",
|
||||||
Folder: "Examples",
|
Folder: "Examples",
|
||||||
Schedule: "@every 15s",
|
Schedule: "*/1 * * * *",
|
||||||
Command: echoCommand("PySentry test job: timestamp command ran"),
|
Command: echoCommand("PySentry test job: timestamp command ran"),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
LastRun: "Never",
|
|
||||||
NextRun: "After start",
|
|
||||||
LastState: "Ready",
|
|
||||||
Output: "No command output captured yet.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: 3,
|
ID: 3,
|
||||||
Name: "Paused sample",
|
Name: "Paused sample",
|
||||||
Schedule: "@every 1m",
|
Schedule: "@every 1m",
|
||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
LastRun: "Never",
|
|
||||||
NextRun: "Paused",
|
|
||||||
LastState: "Paused",
|
|
||||||
Output: "No command output captured yet.",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||||
|
jobs := []Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Clean job",
|
||||||
|
Schedule: "@every 10s",
|
||||||
|
Command: echoCommand("ok"),
|
||||||
|
Enabled: true,
|
||||||
|
LastRun: "2026-06-14 12:00:00",
|
||||||
|
NextRun: "2026-06-14 12:00:10",
|
||||||
|
LastState: "OK",
|
||||||
|
Output: "stdout: ok",
|
||||||
|
Logs: []RunRecord{
|
||||||
|
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1 @@
|
|||||||
|
IDI_ICON1 ICON "packaging/windows/pysentry.ico"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
param(
|
||||||
|
[string]$Output = "dist\linux\pysentry"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
docker build -f Dockerfile.linux -t pysentry-linux-builder .
|
||||||
|
$containerId = docker create pysentry-linux-builder
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
|
||||||
|
docker cp "${containerId}:/out/pysentry" $Output
|
||||||
|
docker rm $containerId | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Built $Output"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
output="${1:-dist/linux/pysentry}"
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=linux
|
||||||
|
export GOARCH=amd64
|
||||||
|
|
||||||
|
go build -trimpath -ldflags "-s -w" -o "$output" ./cmd/pysentry
|
||||||
|
|
||||||
|
echo "Built $output"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
param(
|
||||||
|
[string]$Output = "dist\windows\pysentry.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$go = "${env:ProgramFiles}\Go\bin\go.exe"
|
||||||
|
if (-not (Test-Path $go)) {
|
||||||
|
$go = "go"
|
||||||
|
}
|
||||||
|
|
||||||
|
$msys2Bin = "C:\msys64\ucrt64\bin"
|
||||||
|
if (Test-Path $msys2Bin) {
|
||||||
|
$env:Path = "$msys2Bin;$env:Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:CGO_ENABLED = "1"
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
|
||||||
|
|
||||||
|
$windres = Get-Command windres.exe -ErrorAction SilentlyContinue
|
||||||
|
if ($windres) {
|
||||||
|
& $windres.Source -O coff -o .\cmd\pysentry\rsrc_windows_amd64.syso .\packaging\windows\pysentry.rc
|
||||||
|
}
|
||||||
|
|
||||||
|
& $go build -trimpath -ldflags "-s -w" -o $Output .\cmd\pysentry
|
||||||
|
|
||||||
|
Write-Host "Built $Output"
|
||||||
Reference in New Issue
Block a user