Add initial Fyne GUI prototype
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"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"
|
||||
)
|
||||
|
||||
const appID = "io.github.pysentry.desktop"
|
||||
|
||||
type job struct {
|
||||
Name string
|
||||
Schedule string
|
||||
Command string
|
||||
Enabled bool
|
||||
LastRun string
|
||||
NextRun string
|
||||
LastState string
|
||||
}
|
||||
|
||||
type event struct {
|
||||
Time string
|
||||
JobName string
|
||||
State string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func Run() {
|
||||
a := app.NewWithID(appID)
|
||||
a.SetIcon(theme.ComputerIcon())
|
||||
|
||||
w := a.NewWindow("PySentry")
|
||||
w.Resize(fyne.NewSize(1120, 720))
|
||||
w.SetContent(newMainView(w))
|
||||
w.ShowAndRun()
|
||||
}
|
||||
|
||||
func newMainView(w fyne.Window) fyne.CanvasObject {
|
||||
jobs := []job{
|
||||
{
|
||||
Name: "Nightly backup",
|
||||
Schedule: "0 2 * * *",
|
||||
Command: "python scripts/backup.py",
|
||||
Enabled: true,
|
||||
LastRun: "Today 02:00",
|
||||
NextRun: "Tomorrow 02:00",
|
||||
LastState: "OK",
|
||||
},
|
||||
{
|
||||
Name: "Health check",
|
||||
Schedule: "*/15 * * * *",
|
||||
Command: "curl -fsS https://example.test/health",
|
||||
Enabled: true,
|
||||
LastRun: "21:00",
|
||||
NextRun: "21:15",
|
||||
LastState: "OK",
|
||||
},
|
||||
{
|
||||
Name: "Rotate logs",
|
||||
Schedule: "30 1 * * 1",
|
||||
Command: "pysentry rotate-logs",
|
||||
Enabled: false,
|
||||
LastRun: "Monday 01:30",
|
||||
NextRun: "Paused",
|
||||
LastState: "Paused",
|
||||
},
|
||||
}
|
||||
events := []event{
|
||||
{Time: "21:00", JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"},
|
||||
{Time: "20:45", JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"},
|
||||
{Time: "02:00", 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"},
|
||||
}
|
||||
|
||||
selected := 0
|
||||
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
schedule := widget.NewLabel(jobs[selected].Schedule)
|
||||
command := widget.NewLabel(jobs[selected].Command)
|
||||
lastRun := widget.NewLabel(jobs[selected].LastRun)
|
||||
nextRun := widget.NewLabel(jobs[selected].NextRun)
|
||||
state := widget.NewLabel(jobs[selected].LastState)
|
||||
recentEvents := widget.NewList(
|
||||
func() int {
|
||||
if len(events) < 5 {
|
||||
return len(events)
|
||||
}
|
||||
return 5
|
||||
},
|
||||
func() fyne.CanvasObject { return widget.NewLabel("event") },
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
item.(*widget.Label).SetText(eventText(events[id]))
|
||||
},
|
||||
)
|
||||
history := newHistoryView(&events)
|
||||
|
||||
updateDetails := func(index int) {
|
||||
if index < 0 || index >= len(jobs) {
|
||||
title.SetText("No job selected")
|
||||
schedule.SetText("")
|
||||
command.SetText("")
|
||||
lastRun.SetText("")
|
||||
nextRun.SetText("")
|
||||
state.SetText("")
|
||||
return
|
||||
}
|
||||
selected = index
|
||||
current := jobs[selected]
|
||||
title.SetText(current.Name)
|
||||
schedule.SetText(current.Schedule)
|
||||
command.SetText(current.Command)
|
||||
lastRun.SetText(current.LastRun)
|
||||
nextRun.SetText(current.NextRun)
|
||||
state.SetText(current.LastState)
|
||||
}
|
||||
refresh := func() {
|
||||
updateDetails(selected)
|
||||
recentEvents.Refresh()
|
||||
history.Refresh()
|
||||
}
|
||||
|
||||
list := widget.NewList(
|
||||
func() int { return len(jobs) },
|
||||
func() fyne.CanvasObject {
|
||||
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
meta := widget.NewLabel("schedule")
|
||||
status := widget.NewLabel("status")
|
||||
return container.NewVBox(name, meta, status)
|
||||
},
|
||||
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[id]
|
||||
name.SetText(current.Name)
|
||||
meta.SetText(current.Schedule + " " + current.Command)
|
||||
status.SetText(statusText(current))
|
||||
},
|
||||
)
|
||||
list.OnSelected = updateDetails
|
||||
list.Select(selected)
|
||||
|
||||
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) {
|
||||
jobs = append(jobs, saved)
|
||||
selected = len(jobs) - 1
|
||||
events = append([]event{newEvent(saved.Name, "Created", "Job was added")}, events...)
|
||||
list.Refresh()
|
||||
list.Select(selected)
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
||||
jobs[selected] = saved
|
||||
events = append([]event{newEvent(saved.Name, "Updated", "Job settings changed")}, events...)
|
||||
list.Refresh()
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
jobs[selected].LastRun = "Just now"
|
||||
jobs[selected].LastState = "OK"
|
||||
if jobs[selected].Enabled {
|
||||
jobs[selected].NextRun = "Waiting for scheduler"
|
||||
}
|
||||
events = append([]event{newEvent(jobs[selected].Name, "OK", "Manual run simulated")}, events...)
|
||||
list.Refresh()
|
||||
refresh()
|
||||
})
|
||||
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
current := &jobs[selected]
|
||||
current.Enabled = !current.Enabled
|
||||
if current.Enabled {
|
||||
current.LastState = "Ready"
|
||||
current.NextRun = "Waiting for scheduler"
|
||||
events = append([]event{newEvent(current.Name, "Resumed", "Job was enabled")}, events...)
|
||||
} else {
|
||||
current.LastState = "Paused"
|
||||
current.NextRun = "Paused"
|
||||
events = append([]event{newEvent(current.Name, "Paused", "Job was disabled")}, events...)
|
||||
}
|
||||
list.Refresh()
|
||||
refresh()
|
||||
})
|
||||
|
||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, layout.NewSpacer())
|
||||
sidebar := container.NewBorder(toolbar, nil, nil, nil, list)
|
||||
|
||||
details := container.NewVBox(
|
||||
title,
|
||||
widget.NewSeparator(),
|
||||
detailRow("Schedule", schedule),
|
||||
detailRow("Command", command),
|
||||
detailRow("Last run", lastRun),
|
||||
detailRow("Next run", nextRun),
|
||||
detailRow("State", state),
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Recent events", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
recentEvents,
|
||||
)
|
||||
|
||||
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()),
|
||||
)
|
||||
tabs.SetTabLocation(container.TabLocationTop)
|
||||
|
||||
return tabs
|
||||
}
|
||||
|
||||
func statusText(j job) string {
|
||||
if !j.Enabled {
|
||||
return "Paused"
|
||||
}
|
||||
return j.LastState
|
||||
}
|
||||
|
||||
func newEvent(jobName string, state string, detail string) event {
|
||||
return event{
|
||||
Time: time.Now().Format("15:04:05"),
|
||||
JobName: jobName,
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func eventText(e event) string {
|
||||
return fmt.Sprintf("%s %s %s %s", e.Time, e.JobName, e.State, e.Detail)
|
||||
}
|
||||
|
||||
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Wrapping = fyne.TextTruncate
|
||||
return container.NewGridWithColumns(2, caption, value)
|
||||
}
|
||||
|
||||
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
name := widget.NewEntry()
|
||||
name.SetPlaceHolder("Nightly backup")
|
||||
name.SetText(current.Name)
|
||||
schedule := widget.NewEntry()
|
||||
schedule.SetPlaceHolder("0 2 * * *")
|
||||
schedule.SetText(current.Schedule)
|
||||
command := widget.NewEntry()
|
||||
command.SetPlaceHolder("python scripts/backup.py")
|
||||
command.SetText(current.Command)
|
||||
enabled := widget.NewCheck("Enabled", nil)
|
||||
enabled.SetChecked(current.Enabled)
|
||||
|
||||
form := dialog.NewForm(
|
||||
title,
|
||||
"Save",
|
||||
"Cancel",
|
||||
[]*widget.FormItem{
|
||||
widget.NewFormItem("Name", name),
|
||||
widget.NewFormItem("Schedule", schedule),
|
||||
widget.NewFormItem("Command", command),
|
||||
widget.NewFormItem("", enabled),
|
||||
},
|
||||
func(saved bool) {
|
||||
if !saved {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
|
||||
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
|
||||
return
|
||||
}
|
||||
current.Name = strings.TrimSpace(name.Text)
|
||||
current.Schedule = strings.TrimSpace(schedule.Text)
|
||||
current.Command = strings.TrimSpace(command.Text)
|
||||
current.Enabled = enabled.Checked
|
||||
if current.LastRun == "" {
|
||||
current.LastRun = "Never"
|
||||
}
|
||||
if current.Enabled {
|
||||
current.NextRun = "Waiting for scheduler"
|
||||
if current.LastState == "" || current.LastState == "Paused" {
|
||||
current.LastState = "Ready"
|
||||
}
|
||||
} else {
|
||||
current.NextRun = "Paused"
|
||||
current.LastState = "Paused"
|
||||
}
|
||||
onSave(current)
|
||||
},
|
||||
w,
|
||||
)
|
||||
form.Resize(fyne.NewSize(560, 280))
|
||||
form.Show()
|
||||
}
|
||||
|
||||
func newHistoryView(events *[]event) *fyne.Container {
|
||||
list := widget.NewList(
|
||||
func() int { return len(*events) },
|
||||
func() fyne.CanvasObject { return widget.NewLabel("event") },
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
item.(*widget.Label).SetText(eventText((*events)[id]))
|
||||
},
|
||||
)
|
||||
return container.NewPadded(list)
|
||||
}
|
||||
|
||||
func settingsView() fyne.CanvasObject {
|
||||
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
|
||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
notifications.SetChecked(true)
|
||||
|
||||
return container.NewPadded(container.NewVBox(
|
||||
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
runOnStartup,
|
||||
minimizeToTray,
|
||||
notifications,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Scheduler", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabel("The scheduler service, job storage, and cron parser come next."),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user