Improve scheduler GUI controls

This commit is contained in:
mixeme
2026-06-14 22:16:54 +03:00
parent 4dfb3e5e40
commit 0a66d9da0e
2 changed files with 267 additions and 27 deletions
+4
View File
@@ -1,4 +1,8 @@
# ---> Python # ---> Python
# ---> Go
bin/
*.exe
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+263 -27
View File
@@ -9,25 +9,33 @@ import (
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/layout" "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"
) )
const appID = "io.github.pysentry.desktop" const appID = "io.github.pysentry.desktop"
const allFolders = "All"
const noFolder = "No folder"
type job struct { type job struct {
ID int
Name string Name string
Folder string
Schedule string Schedule string
Command string Command string
Enabled bool Enabled bool
LastRun string LastRun string
NextRun string NextRun string
LastState string LastState string
Logs []event
Output string
} }
type event struct { type event struct {
Time string Time string
JobID int
JobName string JobName string
State string State string
Detail string Detail string
@@ -38,32 +46,70 @@ func Run() {
a.SetIcon(theme.ComputerIcon()) a.SetIcon(theme.ComputerIcon())
w := a.NewWindow("PySentry") w := a.NewWindow("PySentry")
configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720)) w.Resize(fyne.NewSize(1120, 720))
w.SetContent(newMainView(w)) w.SetContent(newMainView(w))
w.ShowAndRun() w.ShowAndRun()
} }
func configureSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(desktop.App)
if !ok {
return
}
menu := fyne.NewMenu("PySentry",
fyne.NewMenuItem("Show", func() {
w.Show()
w.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", func() {
a.Quit()
}),
)
desk.SetSystemTrayMenu(menu)
w.SetCloseIntercept(func() {
w.Hide()
})
}
func newMainView(w fyne.Window) fyne.CanvasObject { func newMainView(w fyne.Window) fyne.CanvasObject {
jobs := []job{ jobs := []job{
{ {
ID: 1,
Name: "Nightly backup", Name: "Nightly backup",
Folder: "Maintenance",
Schedule: "0 2 * * *", Schedule: "0 2 * * *",
Command: "python scripts/backup.py", Command: "python scripts/backup.py",
Enabled: true, Enabled: true,
LastRun: "Today 02:00", LastRun: "Today 02:00",
NextRun: "Tomorrow 02:00", NextRun: "Tomorrow 02:00",
LastState: "OK", LastState: "OK",
Output: "stdout: backup archive created\nstderr: <empty>",
Logs: []event{
{Time: "Today 02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 42 s"},
{Time: "Yesterday 02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 39 s"},
},
}, },
{ {
ID: 2,
Name: "Health check", Name: "Health check",
Folder: "Monitoring",
Schedule: "*/15 * * * *", Schedule: "*/15 * * * *",
Command: "curl -fsS https://example.test/health", Command: "curl -fsS https://example.test/health",
Enabled: true, Enabled: true,
LastRun: "21:00", LastRun: "21:00",
NextRun: "21:15", NextRun: "21:15",
LastState: "OK", LastState: "OK",
Output: "stdout: HTTP 200 OK\nstderr: <empty>",
Logs: []event{
{Time: "21:00", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"},
{Time: "20:45", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"},
},
}, },
{ {
ID: 3,
Name: "Rotate logs", Name: "Rotate logs",
Schedule: "30 1 * * 1", Schedule: "30 1 * * 1",
Command: "pysentry rotate-logs", Command: "pysentry rotate-logs",
@@ -71,63 +117,81 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
LastRun: "Monday 01:30", LastRun: "Monday 01:30",
NextRun: "Paused", NextRun: "Paused",
LastState: "Paused", LastState: "Paused",
Output: "No command output captured yet.",
Logs: []event{
{Time: "Yesterday 01:30", JobID: 3, JobName: "Rotate logs", State: "Paused", Detail: "Skipped because the job is paused"},
},
}, },
} }
events := []event{ events := []event{
{Time: "21:00", JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"}, {Time: "21:00", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"},
{Time: "20:45", JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"}, {Time: "20:45", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"},
{Time: "02:00", JobName: "Nightly backup", State: "OK", Detail: "Completed in 42 s"}, {Time: "02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 42 s"},
{Time: "Yesterday 01:30", JobName: "Rotate logs", State: "Paused", Detail: "Skipped because the job is paused"}, {Time: "Yesterday 01:30", JobID: 3, JobName: "Rotate logs", State: "Paused", Detail: "Skipped because the job is paused"},
} }
nextJobID := 4
selected := 0 selected := 0
selectedFolder := allFolders
schedulerPaused := false
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
folder := widget.NewLabel(jobs[selected].Folder)
schedule := widget.NewLabel(jobs[selected].Schedule) schedule := widget.NewLabel(jobs[selected].Schedule)
command := widget.NewLabel(jobs[selected].Command) command := widget.NewLabel(jobs[selected].Command)
lastRun := widget.NewLabel(jobs[selected].LastRun) lastRun := widget.NewLabel(jobs[selected].LastRun)
nextRun := widget.NewLabel(jobs[selected].NextRun) nextRun := widget.NewLabel(jobs[selected].NextRun)
state := widget.NewLabel(jobs[selected].LastState) state := widget.NewLabel(jobs[selected].LastState)
recentEvents := widget.NewList( schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewMultiLineEntry()
commandOutput.SetText(jobs[selected].Output)
commandOutput.Disable()
history := newHistoryView(&events)
jobLogs := widget.NewList(
func() int { func() int {
if len(events) < 5 { if selected < 0 || selected >= len(jobs) {
return len(events) return 0
} }
return 5 return len(jobs[selected].Logs)
}, },
func() fyne.CanvasObject { return widget.NewLabel("event") }, func() fyne.CanvasObject { return widget.NewLabel("log") },
func(id widget.ListItemID, item fyne.CanvasObject) { func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(eventText(events[id])) item.(*widget.Label).SetText(eventText(jobs[selected].Logs[id]))
}, },
) )
history := newHistoryView(&events)
updateDetails := func(index int) { updateDetails := func(index int) {
if index < 0 || index >= len(jobs) { if index < 0 || index >= len(jobs) {
title.SetText("No job selected") title.SetText("No job selected")
folder.SetText("")
schedule.SetText("") schedule.SetText("")
command.SetText("") command.SetText("")
lastRun.SetText("") lastRun.SetText("")
nextRun.SetText("") nextRun.SetText("")
state.SetText("") state.SetText("")
commandOutput.SetText("")
return return
} }
selected = index selected = index
current := jobs[selected] current := jobs[selected]
title.SetText(current.Name) title.SetText(current.Name)
folder.SetText(displayFolder(current.Folder))
schedule.SetText(current.Schedule) schedule.SetText(current.Schedule)
command.SetText(current.Command) command.SetText(current.Command)
lastRun.SetText(current.LastRun) lastRun.SetText(current.LastRun)
nextRun.SetText(current.NextRun) nextRun.SetText(current.NextRun)
state.SetText(current.LastState) state.SetText(current.LastState)
commandOutput.SetText(current.Output)
} }
refresh := func() { refresh := func() {
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected) updateDetails(selected)
recentEvents.Refresh() jobLogs.Refresh()
history.Refresh() history.Refresh()
} }
list := widget.NewList( list := widget.NewList(
func() int { return len(jobs) }, func() int { return len(filteredJobs) },
func() fyne.CanvasObject { func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
meta := widget.NewLabel("schedule") meta := widget.NewLabel("schedule")
@@ -140,22 +204,58 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
meta := row.Objects[1].(*widget.Label) meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label) status := row.Objects[2].(*widget.Label)
current := jobs[id] current := jobs[filteredJobs[id]]
name.SetText(current.Name) name.SetText(current.Name)
meta.SetText(current.Schedule + " " + current.Command) meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
status.SetText(statusText(current)) status.SetText(statusText(current))
}, },
) )
list.OnSelected = updateDetails list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(filteredJobs) {
updateDetails(-1)
return
}
updateDetails(filteredJobs[id])
}
list.Select(selected) list.Select(selected)
folderSelect := widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
return
}
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
if len(filteredJobs) == 0 {
selected = -1
updateDetails(-1)
return
}
selected = filteredJobs[0]
list.Select(0)
refresh()
})
folderSelect.SetSelected(selectedFolder)
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { showJobDialog(w, "New job", job{Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) {
saved.ID = nextJobID
nextJobID++
jobs = append(jobs, saved) jobs = append(jobs, saved)
selected = len(jobs) - 1 selected = len(jobs) - 1
events = append([]event{newEvent(saved.Name, "Created", "Job was added")}, events...) created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
events = append([]event{created}, events...)
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
targetFolder := filterValue(saved.Folder)
if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder)
}
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh() list.Refresh()
list.Select(selected) list.Select(displayIndex(filteredJobs, selected))
refresh() refresh()
}) })
}) })
@@ -164,8 +264,15 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
return return
} }
showJobDialog(w, "Edit job", jobs[selected], func(saved job) { showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
saved.ID = jobs[selected].ID
saved.Logs = jobs[selected].Logs
saved.Output = jobs[selected].Output
jobs[selected] = saved jobs[selected] = saved
events = append([]event{newEvent(saved.Name, "Updated", "Job settings changed")}, events...) updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
events = append([]event{updated}, events...)
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
list.Refresh() list.Refresh()
refresh() refresh()
}) })
@@ -174,15 +281,49 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
} }
if schedulerPaused {
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return
}
jobs[selected].LastRun = "Just now" jobs[selected].LastRun = "Just now"
jobs[selected].LastState = "OK" jobs[selected].LastState = "OK"
jobs[selected].Output = "stdout: manual run simulated\nstderr: <empty>"
if jobs[selected].Enabled { if jobs[selected].Enabled {
jobs[selected].NextRun = "Waiting for scheduler" jobs[selected].NextRun = "Waiting for scheduler"
} }
events = append([]event{newEvent(jobs[selected].Name, "OK", "Manual run simulated")}, events...) ran := newEvent(jobs[selected].ID, jobs[selected].Name, "OK", "Manual run simulated")
jobs[selected].Logs = append([]event{ran}, jobs[selected].Logs...)
events = append([]event{ran}, events...)
list.Refresh() list.Refresh()
refresh() refresh()
}) })
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
stopAllButton.OnTapped = func() {
schedulerPaused = !schedulerPaused
if schedulerPaused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Resume all")
stopAllButton.SetIcon(theme.MediaPlayIcon())
for index := range jobs {
if jobs[index].Enabled {
jobs[index].NextRun = "Scheduler paused"
}
}
events = append([]event{newEvent(0, "Scheduler", "Paused", "All job execution paused")}, events...)
} else {
schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Pause all")
stopAllButton.SetIcon(theme.MediaStopIcon())
for index := range jobs {
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
jobs[index].NextRun = "Waiting for scheduler"
}
}
events = append([]event{newEvent(0, "Scheduler", "Resumed", "All job execution resumed")}, events...)
}
list.Refresh()
refresh()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
@@ -192,30 +333,71 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
if current.Enabled { if current.Enabled {
current.LastState = "Ready" current.LastState = "Ready"
current.NextRun = "Waiting for scheduler" current.NextRun = "Waiting for scheduler"
events = append([]event{newEvent(current.Name, "Resumed", "Job was enabled")}, events...) resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
current.Logs = append([]event{resumed}, current.Logs...)
events = append([]event{resumed}, events...)
} else { } else {
current.LastState = "Paused" current.LastState = "Paused"
current.NextRun = "Paused" current.NextRun = "Paused"
events = append([]event{newEvent(current.Name, "Paused", "Job was disabled")}, events...) paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
current.Logs = append([]event{paused}, current.Logs...)
events = append([]event{paused}, events...)
} }
list.Refresh() list.Refresh()
refresh() refresh()
}) })
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
deleted := jobs[selected]
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
jobs = append(jobs[:selected], jobs[selected+1:]...)
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]
}
events = append([]event{newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")}, events...)
list.Refresh()
if selected >= 0 {
list.Select(displayIndex(filteredJobs, selected))
}
refresh()
}, w)
})
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, layout.NewSpacer()) toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
sidebar := container.NewBorder(toolbar, nil, nil, nil, list) 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)
details := container.NewVBox( details := container.NewVBox(
title, title,
widget.NewSeparator(), widget.NewSeparator(),
detailRow("Folder", folder),
detailRow("Schedule", schedule), detailRow("Schedule", schedule),
detailRow("Command", command), detailRow("Command", command),
detailRow("Last run", lastRun), detailRow("Last run", lastRun),
detailRow("Next run", nextRun), detailRow("Next run", nextRun),
detailRow("State", state), detailRow("State", state),
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Recent events", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
recentEvents, commandOutput,
widget.NewSeparator(),
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
jobLogs,
) )
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
@@ -235,9 +417,10 @@ func statusText(j job) string {
return j.LastState return j.LastState
} }
func newEvent(jobName string, state string, detail string) event { func newEvent(jobID int, jobName string, state string, detail string) event {
return event{ return event{
Time: time.Now().Format("15:04:05"), Time: time.Now().Format("15:04:05"),
JobID: jobID,
JobName: jobName, JobName: jobName,
State: state, State: state,
Detail: detail, Detail: detail,
@@ -254,10 +437,60 @@ func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, caption, value) return container.NewGridWithColumns(2, caption, value)
} }
func filteredJobIndexes(jobs []job, folder string) []int {
indexes := make([]int, 0, len(jobs))
for index, current := range jobs {
if folder == allFolders || filterValue(current.Folder) == folder {
indexes = append(indexes, index)
}
}
return indexes
}
func folderOptions(jobs []job) []string {
options := []string{allFolders, noFolder}
seen := map[string]bool{allFolders: true, noFolder: true}
for _, current := range jobs {
folder := strings.TrimSpace(current.Folder)
if folder == "" || seen[folder] {
continue
}
seen[folder] = true
options = append(options, folder)
}
return options
}
func filterValue(folder string) string {
if strings.TrimSpace(folder) == "" {
return noFolder
}
return strings.TrimSpace(folder)
}
func displayFolder(folder string) string {
if strings.TrimSpace(folder) == "" {
return "(" + noFolder + ")"
}
return strings.TrimSpace(folder)
}
func displayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes {
if index == jobIndex {
return display
}
}
return 0
}
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
name := widget.NewEntry() name := widget.NewEntry()
name.SetPlaceHolder("Nightly backup") name.SetPlaceHolder("Nightly backup")
name.SetText(current.Name) name.SetText(current.Name)
folder := widget.NewEntry()
folder.SetPlaceHolder("Maintenance")
folder.SetText(current.Folder)
schedule := widget.NewEntry() schedule := widget.NewEntry()
schedule.SetPlaceHolder("0 2 * * *") schedule.SetPlaceHolder("0 2 * * *")
schedule.SetText(current.Schedule) schedule.SetText(current.Schedule)
@@ -273,6 +506,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
"Cancel", "Cancel",
[]*widget.FormItem{ []*widget.FormItem{
widget.NewFormItem("Name", name), widget.NewFormItem("Name", name),
widget.NewFormItem("Folder", folder),
widget.NewFormItem("Schedule", schedule), widget.NewFormItem("Schedule", schedule),
widget.NewFormItem("Command", command), widget.NewFormItem("Command", command),
widget.NewFormItem("", enabled), widget.NewFormItem("", enabled),
@@ -286,6 +520,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
return return
} }
current.Name = strings.TrimSpace(name.Text) current.Name = strings.TrimSpace(name.Text)
current.Folder = strings.TrimSpace(folder.Text)
current.Schedule = strings.TrimSpace(schedule.Text) current.Schedule = strings.TrimSpace(schedule.Text)
current.Command = strings.TrimSpace(command.Text) current.Command = strings.TrimSpace(command.Text)
current.Enabled = enabled.Checked current.Enabled = enabled.Checked
@@ -323,6 +558,7 @@ func newHistoryView(events *[]event) *fyne.Container {
func settingsView() fyne.CanvasObject { func settingsView() 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(true)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(true) notifications.SetChecked(true)