feat: per-job command timeout with global default
Add an optional per-job run timeout following the overlap_policy inherit pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s guard in runner.RunJob. - domain/storage: new fields, default 30, load-time normalization - runner: RunJob takes an explicit timeout; StartOnly stays untimed so it keeps measuring launch latency only - app: effectiveTimeout resolves under mu into runEnv, threaded to runJob; seam signature and validation updated; DisplayTimeout helper - ui: Timeout entry in the job dialog, Default timeout in Settings, and a Timeout row in the details panel - tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated; version bumped to 0.12.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
@@ -53,6 +54,11 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
overlapSelected = current.OverlapPolicy
|
||||
}
|
||||
overlapSelect.SetSelected(overlapSelected)
|
||||
timeoutEntry := widget.NewEntry()
|
||||
timeoutEntry.SetPlaceHolder("Empty = use global default")
|
||||
if current.TimeoutSeconds > 0 {
|
||||
timeoutEntry.SetText(strconv.Itoa(current.TimeoutSeconds))
|
||||
}
|
||||
|
||||
form := dialog.NewForm(
|
||||
title,
|
||||
@@ -66,6 +72,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
widget.NewFormItem("Arguments", argumentsEntry),
|
||||
widget.NewFormItem("", startOnly),
|
||||
widget.NewFormItem("Overlap policy", overlapSelect),
|
||||
widget.NewFormItem("Timeout (s)", timeoutEntry),
|
||||
widget.NewFormItem("", enabled),
|
||||
},
|
||||
func(saved bool) {
|
||||
@@ -82,6 +89,17 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
|
||||
return
|
||||
}
|
||||
// An empty timeout inherits the global default (0); any entry must be a
|
||||
// positive whole number of seconds.
|
||||
timeoutSeconds := 0
|
||||
if trimmed := strings.TrimSpace(timeoutEntry.Text); trimmed != "" {
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil || parsed <= 0 {
|
||||
dialog.ShowError(fmt.Errorf("timeout must be a positive number of seconds, or empty to use the global default"), w)
|
||||
return
|
||||
}
|
||||
timeoutSeconds = parsed
|
||||
}
|
||||
current.Name = strings.TrimSpace(name.Text)
|
||||
current.Folder = strings.TrimSpace(folderEntry.Text)
|
||||
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
|
||||
@@ -93,6 +111,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
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.
|
||||
@@ -100,6 +119,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
},
|
||||
w,
|
||||
)
|
||||
form.Resize(fyne.NewSize(640, 460))
|
||||
form.Resize(fyne.NewSize(640, 500))
|
||||
form.Show()
|
||||
}
|
||||
|
||||
+3
-3
@@ -72,9 +72,9 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
schedulerPaused := svc.Store().Config.Paused
|
||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
||||
|
||||
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy)
|
||||
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
if selected >= 0 {
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
} else {
|
||||
dp.clear()
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
return
|
||||
}
|
||||
selected = index
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
}
|
||||
|
||||
// list and folderSelect are declared early so closures below can reference
|
||||
|
||||
@@ -22,6 +22,7 @@ type detailsPanel struct {
|
||||
arguments *widget.Label
|
||||
runMode *widget.Label
|
||||
overlapPolicy *widget.Label
|
||||
timeout *widget.Label
|
||||
lastRun *widget.Label
|
||||
nextRun *widget.Label
|
||||
state *widget.Label
|
||||
@@ -34,7 +35,7 @@ type detailsPanel struct {
|
||||
selectedLogs []event
|
||||
}
|
||||
|
||||
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) *detailsPanel {
|
||||
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) *detailsPanel {
|
||||
d := &detailsPanel{
|
||||
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
folder: newJobDetailLabel(""),
|
||||
@@ -43,6 +44,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
||||
arguments: newJobDetailLabel(""),
|
||||
runMode: newJobDetailLabel(""),
|
||||
overlapPolicy: newJobDetailLabel(""),
|
||||
timeout: newJobDetailLabel(""),
|
||||
lastRun: newJobDetailLabel(""),
|
||||
nextRun: newJobDetailLabel(""),
|
||||
state: newJobDetailLabel(""),
|
||||
@@ -69,11 +71,11 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
||||
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
|
||||
},
|
||||
)
|
||||
d.update(firstJob, rt, globalOverlapPolicy)
|
||||
d.update(firstJob, rt, globalOverlapPolicy, globalTimeout)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) {
|
||||
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) {
|
||||
d.title.SetText(j.Name)
|
||||
d.folder.SetText(app.DisplayFolder(j.Folder))
|
||||
d.schedule.SetText(j.Schedule)
|
||||
@@ -81,6 +83,7 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy
|
||||
d.arguments.SetText(app.DisplayArguments(j.Arguments))
|
||||
d.runMode.SetText(app.DisplayRunMode(j))
|
||||
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
||||
d.timeout.SetText(app.DisplayTimeout(j, globalTimeout))
|
||||
d.lastRun.SetText(rt.LastRun)
|
||||
d.nextRun.SetText(rt.NextRun)
|
||||
d.state.SetText(rt.LastState)
|
||||
@@ -101,6 +104,7 @@ func (d *detailsPanel) clear() {
|
||||
d.arguments.SetText("")
|
||||
d.runMode.SetText("")
|
||||
d.overlapPolicy.SetText("")
|
||||
d.timeout.SetText("")
|
||||
d.lastRun.SetText("")
|
||||
d.nextRun.SetText("")
|
||||
d.state.SetText("")
|
||||
@@ -121,8 +125,9 @@ func (d *detailsPanel) container() fyne.CanvasObject {
|
||||
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
||||
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
||||
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
||||
detailRowPair(capW, "Timeout", d.timeout, "State", d.state),
|
||||
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
|
||||
detailRowPair(capW, "State", d.state, "Statistics", d.stats),
|
||||
detailRow(capW, "Statistics", d.stats),
|
||||
)
|
||||
top := container.NewVBox(
|
||||
d.title,
|
||||
@@ -160,7 +165,7 @@ func activityRowsHeight(rows int) float32 {
|
||||
func detailCaptionWidth() float32 {
|
||||
captions := []string{
|
||||
"Folder", "Schedule", "Command", "Arguments", "Run mode",
|
||||
"Overlap policy", "Last run", "Next run", "State", "Statistics",
|
||||
"Overlap policy", "Timeout", "Last run", "Next run", "State", "Statistics",
|
||||
}
|
||||
var width float32
|
||||
for _, c := range captions {
|
||||
|
||||
@@ -24,14 +24,15 @@ func newTestService(t *testing.T) *app.Service {
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
},
|
||||
Config: domain.Config{
|
||||
JobsDir: ".",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
MaxLogAgeDays: 30,
|
||||
ExecutionMode: domain.ExecutionModeParallel,
|
||||
OverlapPolicy: domain.OverlapPolicySkip,
|
||||
KeepRunningInTray: true,
|
||||
NotifyOnFailure: true,
|
||||
JobsDir: ".",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
MaxLogAgeDays: 30,
|
||||
ExecutionMode: domain.ExecutionModeParallel,
|
||||
OverlapPolicy: domain.OverlapPolicySkip,
|
||||
DefaultTimeoutSeconds: 30,
|
||||
KeepRunningInTray: true,
|
||||
NotifyOnFailure: true,
|
||||
},
|
||||
}
|
||||
return app.NewService(store, nil)
|
||||
|
||||
+11
-1
@@ -73,6 +73,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
)
|
||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||
defaultTimeout := widget.NewEntry()
|
||||
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||
jobsDir := widget.NewEntry()
|
||||
jobsDir.SetText(store.Config.JobsDir)
|
||||
jobsDir.OnChanged = func(string) { updateSaveState() }
|
||||
@@ -116,6 +119,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
settingsStatus.SetText("Logs directory is required")
|
||||
return
|
||||
}
|
||||
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if err != nil || timeout <= 0 {
|
||||
settingsStatus.SetText("Default timeout must be a positive number")
|
||||
return
|
||||
}
|
||||
// Build the new config from the form and hand it to the Service, which
|
||||
// validates it, persists config and jobs to the (possibly new) directory,
|
||||
// and runs log cleanup so tightened retention limits take effect at once.
|
||||
@@ -129,6 +137,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
config.NotifyOnFailure = notifications.Checked
|
||||
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||
config.DefaultTimeoutSeconds = timeout
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
return
|
||||
@@ -155,6 +164,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
notifications.Checked != c.NotifyOnFailure ||
|
||||
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
||||
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
||||
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
||||
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
||||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
||||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
||||
@@ -188,6 +198,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
||||
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
||||
settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)),
|
||||
),
|
||||
)
|
||||
rightColumn := container.NewVBox(
|
||||
@@ -293,4 +304,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user