Files
gosentry/src/ui/job_dialog.go
T
mixeme e05adc1703 @
feat: make per-job timeout 0 mean "no timeout" instead of inherit

A per-job timeout now has three distinct states: unset inherits the global
default, an explicit 0 means no timeout and does not inherit, and a positive
value is the per-job limit. Job.TimeoutSeconds became *int so unset and 0 stay
distinguishable in jobs.json.

Also fixes the global default, which could not persist a 0. loadOrCreateConfig
normalized DefaultTimeoutSeconds <= 0 back to 30 on every read of an existing
gosentry.json, so "no timeout" only held until the next restart. The field is
now written unconditionally (no omitempty) 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
2026-07-26 22:02:46 +03:00

126 lines
4.6 KiB
Go

package ui
import (
"fmt"
"strconv"
"strings"
"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/theme"
"fyne.io/fyne/v2/widget"
)
// overlapPolicyInherit is the display label used when a job should inherit the
// global overlap policy. It maps to an empty Job.OverlapPolicy on save.
const overlapPolicyInherit = "(Use global default)"
// showJobDialog opens a create/edit form for a single job. onSave is called
// with the populated job only when the user clicks Save and all fields pass
// validation.
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
name := widget.NewEntry()
name.SetPlaceHolder("Nightly backup")
name.SetText(current.Name)
folderEntry := widget.NewEntry()
folderEntry.SetPlaceHolder("Maintenance")
folderEntry.SetText(current.Folder)
scheduleEntry := widget.NewEntry()
scheduleEntry.SetPlaceHolder("@every 1m")
scheduleEntry.SetText(current.Schedule)
commandEntry := widget.NewEntry()
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
commandEntry.SetText(current.Command)
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFile(w, commandEntry)
})
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
argumentsEntry := widget.NewMultiLineEntry()
argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
argumentsEntry.SetText(current.Arguments)
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
startOnly.SetChecked(current.StartOnly)
enabled := widget.NewCheck("Enabled", nil)
enabled.SetChecked(current.Enabled)
overlapSelect := widget.NewSelect(
[]string{overlapPolicyInherit, string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapSelected := overlapPolicyInherit
if current.OverlapPolicy != "" {
overlapSelected = current.OverlapPolicy
}
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(
title,
"Save",
"Cancel",
[]*widget.FormItem{
widget.NewFormItem("Name", name),
widget.NewFormItem("Folder", folderEntry),
widget.NewFormItem("Schedule", scheduleEntry),
widget.NewFormItem("Command", commandRow),
widget.NewFormItem("Arguments", argumentsEntry),
widget.NewFormItem("", startOnly),
widget.NewFormItem("Overlap policy", overlapSelect),
widget.NewFormItem("Timeout (s)", timeoutEntry),
widget.NewFormItem("", enabled),
},
func(saved bool) {
if !saved {
return
}
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(scheduleEntry.Text) == "" || strings.TrimSpace(commandEntry.Text) == "" {
// These three fields are the minimum executable job definition.
// Folder is optional because ungrouped jobs are a supported workflow.
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
return
}
if err := domain.Validate(strings.TrimSpace(scheduleEntry.Text)); err != nil {
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
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.Folder = strings.TrimSpace(folderEntry.Text)
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
current.Command = strings.TrimSpace(commandEntry.Text)
current.Arguments = strings.TrimSpace(argumentsEntry.Text)
current.StartOnly = startOnly.Checked
current.Enabled = enabled.Checked
current.OverlapPolicy = overlapSelect.Selected
if current.OverlapPolicy == overlapPolicyInherit {
current.OverlapPolicy = ""
}
current.TimeoutSeconds = timeoutSeconds
// The dialog only edits durable configuration. Runtime status is
// initialized (new jobs) or updated (edits) by the caller against the
// runtime map, keyed by job ID.
onSave(current)
},
w,
)
form.Resize(fyne.NewSize(640, 500))
form.Show()
}