feat(ui): two-column settings/details, compact job list, manual run while paused

- Allow manual "Run now" while the scheduler is paused: pause now stops only
  automatic scheduled runs (RunDue), not the user's explicit action. Drop the
  paused guard in Service.RunNow and the UI pause dialog; update tests.
- Cap the details metadata caption width via a new captionValueLayout so a wider
  window feeds extra space to the value column instead of the short caption.
- Reorganize the Settings tab into two columns (Application+Queue / Storage+About)
  with Save spanning the full width; move the Autostart status onto its own line.
- Condense the Jobs list rows with compactVBoxLayout to fit more jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-25 07:51:03 +03:00
parent 43809e5076
commit 9ecb8b61f8
6 changed files with 146 additions and 58 deletions
+29 -14
View File
@@ -335,21 +335,27 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
} }
} }
func TestRunNowRefusedWhilePaused(t *testing.T) { func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran bool done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
ran = true select {
return domain.RunRecord{} case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}
} }
if err := svc.SetGlobalPause(true); err != nil { if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err) t.Fatalf("SetGlobalPause: %v", err)
} }
if err := svc.RunNow(1); err == nil { // Pause stops only scheduled runs; an explicit manual run is still allowed.
t.Error("expected RunNow to be refused while paused") if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow should be allowed while paused: %v", err)
} }
if ran { select {
t.Error("runner must not be invoked while paused") case <-done:
case <-time.After(2 * time.Second):
t.Error("runner was not invoked for a manual run while paused")
} }
} }
@@ -586,23 +592,32 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
svc2 := NewService(svc.store, svc.Jobs()) svc2 := NewService(svc.store, svc.Jobs())
var ran int32 var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
default:
}
return domain.RunRecord{} return domain.RunRecord{}
} }
// RunDue must not start any job while paused. // RunDue must not start any job while paused: the scheduler stays paused after
// a restart that rebuilt the service from a paused store.
svc2.RunDue(time.Now().Add(2 * time.Minute)) svc2.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 { if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store") t.Error("RunDue ran a job on a service rebuilt from a paused store")
} }
// RunNow must be refused. // A manual RunNow is still allowed while paused — pause only stops the
if err := svc2.RunNow(1); err == nil { // scheduler, not the user's explicit action.
t.Error("RunNow should be refused on a service rebuilt from a paused store") if err := svc2.RunNow(1); err != nil {
t.Errorf("RunNow should be allowed while paused: %v", err)
} }
if atomic.LoadInt32(&ran) != 0 { select {
t.Error("runner was invoked despite global pause") case <-runStarted:
case <-time.After(2 * time.Second):
t.Error("manual run was not started on a service rebuilt from a paused store")
} }
} }
+3 -6
View File
@@ -11,8 +11,9 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/runner"
) )
// RunNow starts a manual run of a job. It refuses to run while globally paused — // RunNow starts a manual run of a job. Global pause stops only the scheduler's
// the pause is an emergency stop for all execution — and will not start a job // automatic runs (see RunDue), so a manual "Run now" is allowed even while
// paused — it is the user's explicit, one-off action. It will not start a job
// that is already running. In sequential execution mode it also refuses while // that is already running. In sequential execution mode it also refuses while
// any other job is running, so a manual run never breaks the one-at-a-time // any other job is running, so a manual run never breaks the one-at-a-time
// guarantee. The run itself happens on a background goroutine that records the // guarantee. The run itself happens on a background goroutine that records the
@@ -21,10 +22,6 @@ import (
// "Running" status), not the run's own outcome. // "Running" status), not the run's own outcome.
func (s *Service) RunNow(id int) error { func (s *Service) RunNow(id int) error {
s.mu.Lock() s.mu.Lock()
if s.paused {
s.mu.Unlock()
return errors.New("scheduler is paused")
}
job := s.findByIDLocked(id) job := s.findByIDLocked(id)
if job == nil { if job == nil {
s.mu.Unlock() s.mu.Unlock()
+11 -9
View File
@@ -28,6 +28,12 @@ const maxJobActivityRows = 3
// padding, tightening the block so it fits comfortably on 720p screens. // padding, tightening the block so it fits comfortably on 720p screens.
const detailRowSpacing float32 = -8 const detailRowSpacing float32 = -8
// jobRowSpacing is the (negative) gap between the name, metadata, and status
// lines within each job list row. Like the details panel, it overlaps the
// labels' built-in vertical padding so each row reads as one compact block and
// more jobs are visible without scrolling.
const jobRowSpacing float32 = -8
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar. // newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// It returns the assembled panel and a refresh function the caller invokes // It returns the assembled panel and a refresh function the caller invokes
// whenever the service state may have changed (e.g., from the event subscriber // whenever the service state may have changed (e.g., from the event subscriber
@@ -97,7 +103,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
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")
status := widget.NewLabel("status") status := widget.NewLabel("status")
return container.NewVBox(name, meta, status) return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status)
}, },
func(id widget.ListItemID, item fyne.CanvasObject) { func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container) row := item.(*fyne.Container)
@@ -185,14 +191,10 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
} }
if schedulerPaused { // A manual run is allowed even while the scheduler is paused: pause only
// The global pause is treated as an emergency stop for all execution, // stops automatic scheduled runs, not the user's explicit "Run now".
// including manual "Run now", so the user has one reliable switch. // RunNow still refuses an already-running job (it returns an error); the UI
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) // has always ignored that case silently, so the run simply does not start.
return
}
// RunNow refuses an already-running job (it returns an error); the UI has
// always ignored that case silently, so the run simply does not start.
if err := svc.RunNow(jobs[selected].ID); err != nil { if err := svc.RunNow(jobs[selected].ID); err != nil {
return return
} }
+32 -11
View File
@@ -116,12 +116,13 @@ func (d *detailsPanel) container() fyne.CanvasObject {
// Metadata is laid out in two columns so the block stays half as tall, // Metadata is laid out in two columns so the block stays half as tall,
// keeping the details pane usable on 720p screens where a single column of // keeping the details pane usable on 720p screens where a single column of
// ten rows pushes the minimum window height past the available space. // ten rows pushes the minimum window height past the available space.
capW := detailCaptionWidth()
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing}, rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
detailRowPair("Folder", d.folder, "Schedule", d.schedule), detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
detailRowPair("Command", d.command, "Arguments", d.arguments), detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy), detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
detailRowPair("Last run", d.lastRun, "Next run", d.nextRun), detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
detailRowPair("State", d.state, "Statistics", d.stats), detailRowPair(capW, "State", d.state, "Statistics", d.stats),
) )
top := container.NewVBox( top := container.NewVBox(
d.title, d.title,
@@ -152,16 +153,36 @@ func activityRowsHeight(rows int) float32 {
return (itemHeight+padding)*float32(rows) - padding + 1 return (itemHeight+padding)*float32(rows) - padding + 1
} }
// detailRowPair places two label/value pairs side by side, producing the // detailCaptionWidth returns the width reserved for every metadata caption,
// four-column caption|value|caption|value rows the compact metadata grid uses. // derived from the widest caption label so the value columns all start at the
func detailRowPair(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject { // same x and no caption truncates. Measuring a real label keeps it DPI- and
return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2)) // theme-aware instead of relying on a hand-tuned constant.
func detailCaptionWidth() float32 {
captions := []string{
"Folder", "Schedule", "Command", "Arguments", "Run mode",
"Overlap policy", "Last run", "Next run", "State", "Statistics",
}
var width float32
for _, c := range captions {
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
width = w
}
}
return width
} }
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { // detailRowPair places two label/value pairs side by side, producing the
// four-column caption|value|caption|value rows the compact metadata grid uses.
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, detailRow(captionWidth, l1, v1), detailRow(captionWidth, l2, v2))
}
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate caption.Wrapping = fyne.TextTruncate
return container.NewGridWithColumns(2, caption, value) // A fixed caption width (rather than an even split) means widening the window
// feeds the extra space to the value, not the short caption.
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
} }
func newJobDetailLabel(text string) *widget.Label { func newJobDetailLabel(text string) *widget.Label {
+43
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
) )
type minWidthLayout struct { type minWidthLayout struct {
@@ -107,3 +108,45 @@ func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
object.Resize(fyne.NewSize(size.Width, l.height)) object.Resize(fyne.NewSize(size.Width, l.height))
} }
} }
// captionValueLayout places a fixed-width caption on the left and lets the value
// fill the remaining width, separated by one theme padding. Capping the caption
// stops it from growing with the window (as an even two-column grid would), so
// the extra space a wider window provides goes entirely to the value column. It
// expects exactly two children: caption first, value second.
type captionValueLayout struct {
captionWidth float32
}
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
if len(objects) != 2 {
return fyne.Size{}
}
captionMin, valueMin := objects[0].MinSize(), objects[1].MinSize()
height := captionMin.Height
if valueMin.Height > height {
height = valueMin.Height
}
return fyne.NewSize(l.captionWidth+theme.Padding()+valueMin.Width, height)
}
func (l captionValueLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
if len(objects) != 2 {
return
}
caption, value := objects[0], objects[1]
captionWidth := l.captionWidth
if captionWidth > size.Width {
captionWidth = size.Width
}
caption.Move(fyne.NewPos(0, 0))
caption.Resize(fyne.NewSize(captionWidth, size.Height))
valueX := captionWidth + theme.Padding()
valueWidth := size.Width - valueX
if valueWidth < 0 {
valueWidth = 0
}
value.Move(fyne.NewPos(valueX, 0))
value.Resize(fyne.NewSize(valueWidth, size.Height))
}
+28 -18
View File
@@ -19,7 +19,6 @@ import (
const settingsLabelWidth float32 = 140 const settingsLabelWidth float32 = 140
const settingsControlWidth float32 = 330 const settingsControlWidth float32 = 330
const settingsStatusWidth float32 = 280
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
// settingsRowSpacing is the (negative) gap between rows of the settings form, // settingsRowSpacing is the (negative) gap between rows of the settings form,
@@ -76,6 +75,10 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry() maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
// Autostart status sits on its own row beneath the checkbox (rather than
// beside it) so the Application section fits within a half-width column.
// Truncating keeps a long status message from forcing the column wider.
autostartStatus.Wrapping = fyne.TextTruncate
settingsStatus := widget.NewLabel("") settingsStatus := widget.NewLabel("")
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
@@ -123,16 +126,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsStatus.SetText("Saved") settingsStatus.SetText("Saved")
}) })
// The form is grouped into sections in an outer VBox. The outer box keeps the // The form is split into two columns so a wide window uses its horizontal
// theme's normal padding, so the separators and the editable Storage fields // space instead of stretching into one tall strip. The left column holds the
// get proper breathing room; only the label-only sections are condensed with // toggles (Application, Queue); the right holds the editable Storage fields and
// the tight settingsSection spacing. Wrapping the whole thing in a vertical // the read-only About block. Save spans the full width below both columns.
// scroll keeps its minimum height small so it does not dictate the window's leftColumn := container.NewVBox(
// minimum height (AppTabs sizes to the tallest tab) and it scrolls on short
// 720p screens.
return container.NewVScroll(container.NewPadded(container.NewVBox(
settingsSection("Application", settingsSection("Application",
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus), settingsRow("Autostart", container.New(minWidthLayout{width: settingsControlWidth}, startOnLogin)),
// Autostart status sits on its own row, aligned under the checkbox via an
// empty caption, so the Application section fits in a half-width column.
settingsRow("", autostartStatus),
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
), ),
@@ -141,7 +144,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
), ),
widget.NewSeparator(), )
rightColumn := container.NewVBox(
// Storage holds editable entry fields. It uses the default VBox spacing // Storage holds editable entry fields. It uses the default VBox spacing
// (not the condensed section layout) so the entry boxes keep a visible // (not the condensed section layout) so the entry boxes keep a visible
// gap between them instead of merging into one block. // gap between them instead of merging into one block.
@@ -153,8 +157,6 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("Max log files", maxLogFiles), settingsRow("Max log files", maxLogFiles),
settingsRow("Max log age days", maxLogAgeDays), settingsRow("Max log age days", maxLogAgeDays),
), ),
saveSettings,
settingsStatus,
widget.NewSeparator(), widget.NewSeparator(),
settingsSection("About", settingsSection("About",
settingsRow("GoSentry", widget.NewLabel(app.Version)), settingsRow("GoSentry", widget.NewLabel(app.Version)),
@@ -162,6 +164,19 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("Fyne", widget.NewLabel(fyneVersion())), settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))), settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
), ),
)
// The two columns sit in a top-aligned grid; Save spans the full width below.
// Wrapping the whole thing in a vertical scroll keeps its minimum height small
// so it does not dictate the window's minimum height (AppTabs sizes to the
// tallest tab) and it scrolls on short 720p screens.
return container.NewVScroll(container.NewPadded(container.NewVBox(
container.NewGridWithColumns(2, leftColumn, rightColumn),
widget.NewSeparator(),
// Save button and its status share one row so an empty status (the common
// case) does not leave a blank line above the separator. The status appears
// beside the button once a save reports a result.
container.NewHBox(saveSettings, settingsStatus),
))) )))
} }
@@ -234,8 +249,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewBorder(nil, nil, captionBox, nil, value) return container.NewBorder(nil, nil, captionBox, nil, value)
} }
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
}