From c36bd60022c423c270223a5d46806a8b0fc7a95d Mon Sep 17 00:00:00 2001 From: Mix Date: Sat, 10 Oct 2026 23:45:05 +0300 Subject: [PATCH] panel-redesign: an idle one-shot helper is "on demand", not "stopped" The owner's choice (2026-10-10) for the last line of the Processes table on Server > Health: postfix-reload runs when the configuration is reloaded and exits, so between runs supervisord calls it STOPPED - a word that reads as a fault beside six running programs, in a box that rightly says ok. Its row now has the neutral tag "on demand" and says when it last ran (or that it has not run since the container started). A one-shot in trouble keeps supervisord's word and level, and a daemon that is stopped is still an error. go test -count=1 ./internal/web/...: ok Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ internal/web/handlers/handlers_status.go | 22 ++++++++++++++++--- internal/web/handlers/handlers_status_test.go | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd04b32..78aa307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version running. The Milter sockets card no longer clips its second line with three sockets, and a DKIM value is shown whole instead of scrolling inside its field. +- Server › Health shows the idle reload helper (`postfix-reload`) as + **on demand**, with when it last ran, instead of `stopped` beside the + running programs (owner, 2026-10-10). - The confirmation before deleting a user names the domains that follow that user's default report address and will be left without one; it used to say the domains were not affected (replaced before release — see the diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index c5ea020..7356fac 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -150,9 +150,7 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health { page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status))) page.ProcessError = c["ProcessError"].(bool) for _, p := range c["Processes"].([]health.Process) { - page.ProcessRows = append(page.ProcessRows, view.ProcessRow{ - Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail, - }) + page.ProcessRows = append(page.ProcessRows, processRow(p)) } cert := c["Cert"].(health.Certificate) @@ -194,6 +192,24 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health { return page } +// processRow is one line of the Processes table. A one-shot helper that sits +// idle is not "stopped" the way a daemon is: it runs when asked and exits, so +// its row says "on demand" in a neutral tag and when it last ran, instead of a +// word that reads as a fault beside the running programs. +func processRow(p health.Process) view.ProcessRow { + if p.OneShot && p.Status == health.StatusOK && p.State != "RUNNING" { + detail := "Runs when the configuration is reloaded" + switch { + case p.Detail == "" || strings.EqualFold(p.Detail, "Not started"): + detail += "; not run since the container started" + default: + detail += "; last run: " + p.Detail + } + return view.ProcessRow{Name: p.Name, State: view.Tag{Status: "off", Label: "on demand"}, Detail: detail} + } + return view.ProcessRow{Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail} +} + // machineRows is the Machine table: processor and memory as bars with their // figure, the network as rates, and for each the sentence the sampler gave. A // reading that could not be taken keeps its row, without a figure. diff --git a/internal/web/handlers/handlers_status_test.go b/internal/web/handlers/handlers_status_test.go index c0adf63..003117b 100644 --- a/internal/web/handlers/handlers_status_test.go +++ b/internal/web/handlers/handlers_status_test.go @@ -1,6 +1,7 @@ package handlers import ( + "strings" "testing" "github.com/mixeme/selfpost/internal/dnscheck" @@ -105,3 +106,24 @@ func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) { t.Errorf("card = %q / %q, want 1 of 1 running / postfix-reload", card.Value, card.Sub) } } + +// An idle one-shot helper is "on demand", not "stopped"; a daemon keeps its +// state word, and a one-shot in trouble keeps its own. +func TestProcessRowOfAnIdleOneShotSaysOnDemand(t *testing.T) { + idle := processRow(health.Process{Name: "postfix-reload", State: "STOPPED", Detail: "Not started", Status: health.StatusOK, OneShot: true}) + if idle.State.Label != "on demand" || idle.State.Status != "off" || !strings.Contains(idle.Detail, "not run since the container started") { + t.Errorf("idle one-shot = %+v", idle) + } + ran := processRow(health.Process{Name: "postfix-reload", State: "EXITED", Detail: "Oct 10 08:42 PM", Status: health.StatusOK, OneShot: true}) + if ran.State.Label != "on demand" || !strings.HasSuffix(ran.Detail, "last run: Oct 10 08:42 PM") { + t.Errorf("one-shot that ran = %+v", ran) + } + bad := processRow(health.Process{Name: "postfix-reload", State: "FATAL", Detail: "Exited too quickly", Status: health.StatusError, OneShot: true}) + if bad.State.Label != "fatal" || bad.State.Status != "error" || bad.Detail != "Exited too quickly" { + t.Errorf("one-shot in trouble = %+v", bad) + } + daemon := processRow(health.Process{Name: "postfix", State: "STOPPED", Detail: "Oct 10 08:42 PM", Status: health.StatusError}) + if daemon.State.Label != "stopped" || daemon.State.Status != "error" { + t.Errorf("stopped daemon = %+v", daemon) + } +}