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}})
var ran bool
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
ran = true
return domain.RunRecord{}
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while paused")
// Pause stops only scheduled runs; an explicit manual run is still allowed.
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow should be allowed while paused: %v", err)
}
if ran {
t.Error("runner must not be invoked while paused")
select {
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())
var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
default:
}
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))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store")
}
// RunNow must be refused.
if err := svc2.RunNow(1); err == nil {
t.Error("RunNow should be refused on a service rebuilt from a paused store")
// A manual RunNow is still allowed while paused — pause only stops the
// scheduler, not the user's explicit action.
if err := svc2.RunNow(1); err != nil {
t.Errorf("RunNow should be allowed while paused: %v", err)
}
if atomic.LoadInt32(&ran) != 0 {
t.Error("runner was invoked despite global pause")
select {
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"
)
// RunNow starts a manual run of a job. It refuses to run while globally paused —
// the pause is an emergency stop for all execution — and will not start a job
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
// 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
// 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
@@ -21,10 +22,6 @@ import (
// "Running" status), not the run's own outcome.
func (s *Service) RunNow(id int) error {
s.mu.Lock()
if s.paused {
s.mu.Unlock()
return errors.New("scheduler is paused")
}
job := s.findByIDLocked(id)
if job == nil {
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.
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.
// 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
@@ -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})
meta := widget.NewLabel("schedule")
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) {
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) {
return
}
if schedulerPaused {
// The global pause is treated as an emergency stop for all execution,
// including manual "Run now", so the user has one reliable switch.
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
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.
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
// RunNow still 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 {
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,
// keeping the details pane usable on 720p screens where a single column of
// ten rows pushes the minimum window height past the available space.
capW := detailCaptionWidth()
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
detailRowPair("Folder", d.folder, "Schedule", d.schedule),
detailRowPair("Command", d.command, "Arguments", d.arguments),
detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
detailRowPair("Last run", d.lastRun, "Next run", d.nextRun),
detailRowPair("State", d.state, "Statistics", d.stats),
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, "Last run", d.lastRun, "Next run", d.nextRun),
detailRowPair(capW, "State", d.state, "Statistics", d.stats),
)
top := container.NewVBox(
d.title,
@@ -152,16 +153,36 @@ func activityRowsHeight(rows int) float32 {
return (itemHeight+padding)*float32(rows) - padding + 1
}
// 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(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2))
// detailCaptionWidth returns the width reserved for every metadata caption,
// derived from the widest caption label so the value columns all start at the
// same x and no caption truncates. Measuring a real label keeps it DPI- and
// 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.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 {
+43
View File
@@ -2,6 +2,7 @@ package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
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))
}
}
// 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 settingsControlWidth float32 = 330
const settingsStatusWidth float32 = 280
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
// 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))
maxLogAgeDays := widget.NewEntry()
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("")
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")
})
// The form is grouped into sections in an outer VBox. The outer box keeps the
// theme's normal padding, so the separators and the editable Storage fields
// get proper breathing room; only the label-only sections are condensed with
// the tight settingsSection spacing. 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(
// The form is split into two columns so a wide window uses its horizontal
// space instead of stretching into one tall strip. The left column holds the
// toggles (Application, Queue); the right holds the editable Storage fields and
// the read-only About block. Save spans the full width below both columns.
leftColumn := container.NewVBox(
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("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("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
// (not the condensed section layout) so the entry boxes keep a visible
// 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 age days", maxLogAgeDays),
),
saveSettings,
settingsStatus,
widget.NewSeparator(),
settingsSection("About",
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("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)
}
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))
}