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
|
||||
# ---> Go
|
||||
bin/
|
||||
dist/
|
||||
cmd/pysentry/*.syso
|
||||
*.exe
|
||||
pysentry.yaml
|
||||
jobs.yaml
|
||||
logs/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__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 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
|
||||
|
||||
- Go 1.22 or newer
|
||||
- A C compiler for Fyne builds on Windows, for example MSYS2/MinGW-w64
|
||||
Common:
|
||||
|
||||
## Run
|
||||
- Go 1.22 or newer.
|
||||
|
||||
```powershell
|
||||
go mod tidy
|
||||
go run ./cmd/pysentry
|
||||
Windows:
|
||||
|
||||
- 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
|
||||
.\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
|
||||
```
|
||||
|
||||
## 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.
|
||||
- `internal/app` contains the first Fyne-based interface prototype.
|
||||
- `internal/core` contains YAML storage, command execution, and the first scheduler loop.
|
||||
- Jobs can be created, edited, paused/resumed, run manually, and persisted to YAML.
|
||||
- Settings are stored in `pysentry.yaml` next to the executable.
|
||||
- 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.
|
||||
- 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`.
|
||||
- Run history records include a `trigger` value such as `Manual`, `Schedule`, or `UI`.
|
||||
- Cron expression parsing is planned for the next phase.
|
||||
- `internal/app` contains the GUI.
|
||||
- `internal/core` contains YAML storage, command execution, scheduling, and log cleanup.
|
||||
- `assets` contains app icons.
|
||||
- `scripts` contains build helpers.
|
||||
|
||||
## Dependencies
|
||||
|
||||
PySentry keeps the direct dependency list intentionally small:
|
||||
|
||||
- `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 (
|
||||
fyne.io/fyne/v2 v2.5.3
|
||||
github.com/robfig/cron/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/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/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/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
||||
+39
-20
@@ -1,9 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,6 +26,9 @@ const noFolder = "No folder"
|
||||
type job = core.Job
|
||||
type event = core.RunRecord
|
||||
|
||||
//go:embed assets/pysentry-icon.png
|
||||
var iconBytes []byte
|
||||
|
||||
func Run() {
|
||||
a := app.NewWithID(appID)
|
||||
a.SetIcon(loadAppIcon())
|
||||
@@ -39,19 +41,7 @@ func Run() {
|
||||
}
|
||||
|
||||
func loadAppIcon() fyne.Resource {
|
||||
candidates := []string{}
|
||||
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()
|
||||
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
|
||||
}
|
||||
|
||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||
@@ -379,7 +369,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
|
||||
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)
|
||||
|
||||
@@ -557,14 +547,22 @@ func newHistoryView(events *[]event) *fyne.Container {
|
||||
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)
|
||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
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.SetText(store.Config.LogsDir)
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
})
|
||||
maxLogFiles := widget.NewEntry()
|
||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||
maxLogAgeDays := widget.NewEntry()
|
||||
@@ -583,6 +581,15 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
||||
return
|
||||
}
|
||||
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.MaxLogAgeDays = days
|
||||
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
||||
@@ -591,6 +598,10 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
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 {
|
||||
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
||||
return
|
||||
@@ -606,9 +617,8 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
||||
detailRow("Jobs YAML", widget.NewLabel(store.Paths.JobsPath)),
|
||||
detailRow("Jobs directory", widget.NewLabel(store.Paths.JobsDir)),
|
||||
detailRow("Logs directory", logsDir),
|
||||
detailRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||
detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||
detailRow("Max log files", maxLogFiles),
|
||||
detailRow("Max log age days", maxLogAgeDays),
|
||||
saveSettings,
|
||||
@@ -618,3 +628,12 @@ func settingsView(store *core.Store) fyne.CanvasObject {
|
||||
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"`
|
||||
Command string `yaml:"command"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
LastRun string `yaml:"last_run,omitempty"`
|
||||
NextRun string `yaml:"next_run,omitempty"`
|
||||
LastState string `yaml:"last_state,omitempty"`
|
||||
Logs []RunRecord `yaml:"activity,omitempty"`
|
||||
Output string `yaml:"last_output,omitempty"`
|
||||
LastRun string `yaml:"-"`
|
||||
NextRun string `yaml:"-"`
|
||||
LastState string `yaml:"-"`
|
||||
Logs []RunRecord `yaml:"-"`
|
||||
Output string `yaml:"-"`
|
||||
|
||||
nextDue time.Time
|
||||
}
|
||||
|
||||
+21
-10
@@ -5,8 +5,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"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 {
|
||||
store *Store
|
||||
jobs *[]Job
|
||||
@@ -146,24 +150,31 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||
}
|
||||
|
||||
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
|
||||
interval, ok := parseEvery(job.Schedule)
|
||||
next, ok := nextRunTime(job.Schedule, from)
|
||||
if !ok {
|
||||
job.NextRun = "Unsupported schedule"
|
||||
job.NextRun = "Invalid schedule"
|
||||
job.nextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
job.nextDue = from.Add(interval)
|
||||
job.nextDue = next
|
||||
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)
|
||||
if !strings.HasPrefix(schedule, "@every ") {
|
||||
return 0, false
|
||||
if schedule == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||
if err != nil || interval <= 0 {
|
||||
return 0, false
|
||||
if strings.HasPrefix(schedule, "@every ") {
|
||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
normalizeJobs(jobs)
|
||||
if err := store.SaveJobs(jobs); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return store, jobs, nil
|
||||
}
|
||||
|
||||
@@ -93,6 +97,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
func loadOrCreateJobs(path string) ([]Job, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
jobs := defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
@@ -107,6 +112,42 @@ func loadOrCreateJobs(path string) ([]Job, error) {
|
||||
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 {
|
||||
return resolveConfiguredDir(appDir, jobsDir)
|
||||
}
|
||||
@@ -138,39 +179,27 @@ func writeYAML(path string, value any) error {
|
||||
func defaultJobs() []Job {
|
||||
return []Job{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "Hello scheduler",
|
||||
Folder: "Examples",
|
||||
Schedule: "@every 10s",
|
||||
Command: echoCommand("PySentry test job: scheduler is alive"),
|
||||
Enabled: true,
|
||||
LastRun: "Never",
|
||||
NextRun: "After start",
|
||||
LastState: "Ready",
|
||||
Output: "No command output captured yet.",
|
||||
ID: 1,
|
||||
Name: "Hello scheduler",
|
||||
Folder: "Examples",
|
||||
Schedule: "@every 10s",
|
||||
Command: echoCommand("PySentry test job: scheduler is alive"),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "Write timestamp",
|
||||
Folder: "Examples",
|
||||
Schedule: "@every 15s",
|
||||
Command: echoCommand("PySentry test job: timestamp command ran"),
|
||||
Enabled: true,
|
||||
LastRun: "Never",
|
||||
NextRun: "After start",
|
||||
LastState: "Ready",
|
||||
Output: "No command output captured yet.",
|
||||
ID: 2,
|
||||
Name: "Write timestamp",
|
||||
Folder: "Examples",
|
||||
Schedule: "*/1 * * * *",
|
||||
Command: echoCommand("PySentry 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,
|
||||
LastRun: "Never",
|
||||
NextRun: "Paused",
|
||||
LastState: "Paused",
|
||||
Output: "No command output captured yet.",
|
||||
ID: 3,
|
||||
Name: "Paused sample",
|
||||
Schedule: "@every 1m",
|
||||
Command: echoCommand("This paused sample should not run until enabled"),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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