diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc0315..996f949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Changed + +- **Overview's head says it as the mockup does** (overview-head): "Mail is + flowing. One thing needs attention." - or "Nothing needs attention", or + "Mail may not be flowing" when a check fails - and under it each check + that is not fine with its value ("TLS certificate: expires in 12 days."). + ## [2.14.0] - 2026-10-17 Password reset by e-mail, to a confirmed address. Migration 0013; schema 13. diff --git a/docs/guide.md b/docs/guide.md index f902ad1..6476a76 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -455,7 +455,9 @@ assumptions stay here in `docs/guide.md`. ### Overview and Health **Overview** (`/overview`, global role) shows the stamp — the worst of its -checks — and one card per check: machine (processor and memory), supervised +checks — with a sentence beside it ("Mail is flowing. One thing needs +attention.", "Mail may not be flowing." when a check fails) and, under it, +each check that is not fine with its value; then one card per check: machine (processor and memory), supervised processes (Postfix, the panel - whose roles include the DKIM signer - and their helpers), TLS certificate, queue, milter sockets and reverse DNS. A check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay diff --git a/internal/web/handlers/handlers_filter_socket_test.go b/internal/web/handlers/handlers_filter_socket_test.go index 3a30b46..5883fb6 100644 --- a/internal/web/handlers/handlers_filter_socket_test.go +++ b/internal/web/handlers/handlers_filter_socket_test.go @@ -162,7 +162,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) { rec := httptest.NewRecorder() h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal)) page := rec.Body.String() - for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets (4 of 5 answering)`} { + for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets: 4 of 5 answering`} { if !strings.Contains(page, want) { t.Errorf("action %q: Overview is missing %q", tt.action, want) } diff --git a/internal/web/view/overview.go b/internal/web/view/overview.go index 6816dcb..0f066ae 100644 --- a/internal/web/view/overview.go +++ b/internal/web/view/overview.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode" ) // Overview is the data of the Overview page. Cards are the checks of Health @@ -89,10 +90,12 @@ func (o *Overview) HeadOOB() Head { return h } -// overviewHead is the stamp and the sentence under it. The stamp takes the -// worst card, counts the cards that are not fine, and the lead names them with -// their detail so the operator sees what to open without scanning the grid. A -// card that could not be read is a warning, never a pass (cardLevel). +// overviewHead is the stamp and the sentence under it (mockup overview.html, +// the owner's "1" of 2026-10-17). The stamp takes the worst card and counts +// the cards that are not fine; the title says whether mail flows and how many +// things need attention, and the lead names them with their values so the +// operator sees what to open without scanning the grid. A card that could not +// be read is a warning, never a pass (cardLevel). func overviewHead(host string, cards []HealthCard, updated time.Time) Head { level, bad := LevelOK, []HealthCard{} for _, c := range cards { @@ -105,12 +108,14 @@ func overviewHead(host string, cards []HealthCard, updated time.Time) Head { } } - title, word, count := "Everything is running normally.", "OK", len(cards) + title, word, count := "Mail is flowing. Nothing needs attention.", "OK", len(cards) switch level { case LevelWarn: - title, word, count = "Running, with warnings.", "WARN", len(bad) + title, word, count = "Mail is flowing. "+needAttention(len(bad)), "WARN", len(bad) case LevelFail: - title, word, count = "A component needs attention.", "FAIL", len(bad) + // A failed card is Postfix, a milter socket, a queue that cannot be + // read: what can stop mail. + title, word, count = "Mail may not be flowing. "+needAttention(len(bad)), "FAIL", len(bad) } h := Head{ @@ -121,18 +126,37 @@ func overviewHead(host string, cards []HealthCard, updated time.Time) Head { Note: []string{"Updated " + updated.Local().Format("15:04:05"), "refreshes on its own"}, } if len(bad) > 0 { - lead := Text{{Text: "Needs attention: "}} + var lead Text for i, c := range bad { if i > 0 { - lead = append(lead, Inline{Text: ", "}) + lead = append(lead, Inline{Text: "; "}) } - lead = append(lead, Link(c.Href, c.Name), Inline{Text: " (" + c.Value + ")"}) + lead = append(lead, Link(c.Href, c.Name), Inline{Text: ": " + lowerFirst(c.Value)}) } h.Lead = append(lead, Inline{Text: "."}) } return h } +// needAttention is the second sentence of the title for n things. +func needAttention(n int) string { + if n == 1 { + return "One thing needs attention." + } + return strconv.Itoa(n) + " things need attention." +} + +// lowerFirst writes a card's value inside a sentence: "Expires in 12 days" +// becomes "expires in 12 days", but a word that starts with two capitals - an +// abbreviation, "CPU 98 %" - stays as it is. +func lowerFirst(s string) string { + r := []rune(s) + if len(r) > 1 && unicode.IsUpper(r[0]) && unicode.IsLower(r[1]) { + r[0] = unicode.ToLower(r[0]) + } + return string(r) +} + // FormatCount writes a whole number with its thousands separated by a no-break // space: "1 284". func FormatCount(n int64) string { diff --git a/internal/web/view/overview_head_test.go b/internal/web/view/overview_head_test.go new file mode 100644 index 0000000..04cc034 --- /dev/null +++ b/internal/web/view/overview_head_test.go @@ -0,0 +1,38 @@ +package view + +import ( + "testing" + "time" +) + +// The head says whether mail flows and how many things need attention, and +// the lead names each with its value inside the sentence (mockup +// overview.html; the owner's "1", 2026-10-17). +func TestOverviewHeadSentences(t *testing.T) { + ok := HealthCard{Name: "Processes", Value: "5 of 5 running", Href: "/server/health"} + cert := HealthCard{Name: "TLS certificate", Value: "Expires in 12 days", Href: "/server/health", Level: LevelWarn} + cpu := HealthCard{Name: "Machine", Value: "CPU 98 %", Href: "/server/health", Level: LevelWarn} + postfix := HealthCard{Name: "Processes", Value: "4 of 5 running", Href: "/server/health", Level: LevelFail} + for _, c := range []struct { + name string + cards []HealthCard + title, lead string + }{ + {"all fine", []HealthCard{ok}, "Mail is flowing. Nothing needs attention.", ""}, + {"one warning", []HealthCard{ok, cert}, "Mail is flowing. One thing needs attention.", "TLS certificate: expires in 12 days."}, + {"two warnings", []HealthCard{cert, cpu}, "Mail is flowing. 2 things need attention.", "TLS certificate: expires in 12 days; Machine: CPU 98 %."}, + {"a failure", []HealthCard{postfix, cert}, "Mail may not be flowing. 2 things need attention.", "Processes: 4 of 5 running; TLS certificate: expires in 12 days."}, + } { + h := overviewHead("mail.example.org", c.cards, time.Now()) + if h.Title != c.title { + t.Errorf("%s: title %q, want %q", c.name, h.Title, c.title) + } + var lead string + for _, in := range h.Lead { + lead += in.Text + } + if lead != c.lead { + t.Errorf("%s: lead %q, want %q", c.name, lead, c.lead) + } + } +} diff --git a/internal/web/view/pages_test.go b/internal/web/view/pages_test.go index 475508b..d4608e6 100644 --- a/internal/web/view/pages_test.go +++ b/internal/web/view/pages_test.go @@ -60,9 +60,9 @@ func pageLacks(t *testing.T, page, out string, gones ...string) { func TestOverviewHeadFollowsTheCards(t *testing.T) { out := renderSignedIn(t, "overview", overviewFixture()) pageHas(t, "Overview", out, - `

Running, with warnings.

`, + `

Mail is flowing. One thing needs attention.

`, `class="sp-postmark sp-warn"`, `WARN`, `1 OF 6 CHECKS`, - `Needs attention: TLS certificate (Expires in 12 days).`, + `TLS certificate: expires in 12 days.`, `Updated 14:02:31
refreshes on its own`, `

mail.example.org

`, `class="sp-warn"`, // the certificate card `href="/server/health"`, `>All details`,