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:
+29
-14
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user