From 783dabbea5fcb917b2d0dd086c58eab17a4a2dfd Mon Sep 17 00:00:00 2001 From: Mix Date: Fri, 9 Oct 2026 22:07:10 +0300 Subject: [PATCH] panel-redesign: Server > Health; Overview reduced to verdicts; @routes off Stage 1, step 5 - and the end of stage 1. - GET /server/health (+ /fragment) shows every check in full and is the home of POST .../recheck and POST .../reload, which now return there. - GET /overview (+ /fragment) shows the overall verdict and six cards - machine, processes, TLS certificate, queue, milter sockets, reverse DNS - each a view.HealthCard (the typed input of the kit's health_card) that links to its table on Health. A check that could not run is a warning, not "ok". - Both pages read healthChecks(), so a card and its table cannot disagree. Until stage 2 they share the old status template, switched by a flag. legacy_pages.txt: @routes removed. TestRoutesFollowNavigation is enforced from here on: every path of plan section Routes is registered with its method, no pre-2.0 path is answered, and no template links to a path the router does not serve. Not in this step, left for the Overview page of stage 2: the outbound and inbound domain summaries the mockup shows under the cards. Tests: the six cards from one reading of the checks (values, levels, links, and the unverified case); the two modes of the page - Overview without the tables and actions, Health with them, each polling its own fragment. go test ./... green except the two Windows-only failures in internal/domain and internal/logtail. Roadmap 15/27. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/plans/panel-redesign.md | 2 +- docs/roadmap.md | 2 +- internal/web/handlers/authz_test.go | 8 +- internal/web/handlers/handlers_domains.go | 2 +- internal/web/handlers/handlers_status.go | 151 +++++++++++++++++- internal/web/handlers/handlers_status_test.go | 117 ++++++++++++++ internal/web/view/legacy_pages.txt | 1 - internal/web/view/templates/status.html | 7 +- internal/web/view/templates/status_body.html | 25 +++ internal/web/view/templates_test.go | 2 +- internal/web/web.go | 8 +- 12 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 internal/web/handlers/handlers_status_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 436923e..268e695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version together or not at all — also at creation, so an application can be added with its limit already in place. The page that shows a generated password is the response to its POST only and is sent with `Cache-Control: no-store`. +- panel-redesign (stage 1): **Server › Health** (`/server/health`) holds the + tables of the old Status page and its two actions, *Re-check DNS* and + *Reload configuration*. **Overview** (`/overview`) is reduced to one + verdict per check — machine, processes, TLS certificate, queue, milter + sockets, reverse DNS — each leading to its table on Health; a check that + could not run shows as a warning, never as fine. Both read the same checks. ### Changed diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index d555cb2..c8718ed 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -509,7 +509,7 @@ only when both are done. Every stage-2 step ends with the evidence of - [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus** - [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus** - [x] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus** -- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus** +- [x] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus** **Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs) diff --git a/docs/roadmap.md b/docs/roadmap.md index bfc67b7..ff42b6b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | panel-notifications | E-mail notifications about important events | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — | -| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 14/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | +| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 15/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go index e8fe848..1007904 100644 --- a/internal/web/handlers/authz_test.go +++ b/internal/web/handlers/authz_test.go @@ -38,9 +38,11 @@ var globalOnlyRoutes = []route{ {"POST", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil}, {"POST", "/server/backup/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil}, - {"GET", "/overview", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil}, - {"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil}, - {"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil}, + {"GET", "/overview", func(h *Handlers) http.HandlerFunc { return h.HandleOverview }, nil}, + {"GET", "/server/health", func(h *Handlers) http.HandlerFunc { return h.HandleHealth }, nil}, + {"GET", "/server/health/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleHealthFragment }, nil}, + {"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleOverviewFragment }, nil}, + {"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleHealthRecheck }, nil}, {"GET", "/outbound/queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil}, {"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil}, diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index ad98300..d0cb269 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -198,7 +198,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) { return } } - http.Redirect(w, r, "/overview?reloaded=1", http.StatusSeeOther) + http.Redirect(w, r, "/server/health?reloaded=1", http.StatusSeeOther) } func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) { diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index 247e782..615b3e2 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -1,42 +1,177 @@ package handlers import ( + "fmt" "net/http" "strings" + "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/view" ) -func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) { +// The old Status page is two pages in 2.0 (plan § Information architecture): +// +// - Overview answers "is mail flowing?" with one verdict per check, each a +// card that leads to its detail; +// - Server › Health is everything behind those cards — the tables — and the +// home of the two actions, Re-check DNS and Reload configuration. +// +// Both read the same checks (healthChecks), so a card and its table can never +// disagree. Until stage 2 gives each page its own template they share the old +// one, switched by .Overview. + +// HandleOverview shows the verdicts. +func (h *Handlers) HandleOverview(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireGlobal(w, r); !ok { return } - data := h.statusBody() - data["Title"] = "SelfPost — status" + data := h.overviewBody() + data["Title"] = "SelfPost — overview" data["User"] = auth.CurrentUser(r) data["Active"] = "status" data["IsGlobal"] = true + h.view.Render(w, http.StatusOK, "status", data) +} + +// HandleOverviewFragment is the polled part of Overview. +func (h *Handlers) HandleOverviewFragment(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + h.view.RenderFragment(w, http.StatusOK, "status_body", h.overviewBody()) +} + +// HandleHealth shows every check in full, with the two server actions. +func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + data := h.healthChecks() + data["Title"] = "SelfPost — health" + data["User"] = auth.CurrentUser(r) + data["Active"] = "health" + data["IsGlobal"] = true data["Flash"] = statusFlash(r) h.view.Render(w, http.StatusOK, "status", data) } -func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) { +// HandleHealthFragment is the polled part of Health. +func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireGlobal(w, r); !ok { return } - h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody()) + h.view.RenderFragment(w, http.StatusOK, "status_body", h.healthChecks()) } -func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) { +// HandleHealthRecheck re-runs the DNS checks of the server ignoring the cache. +func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireGlobal(w, r); !ok { return } h.dns.Server(h.cfg.Hostname, true) - http.Redirect(w, r, "/overview?rechecked=1", http.StatusSeeOther) + http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther) } -func (h *Handlers) statusBody() map[string]any { +// overviewBody reduces the checks to what Overview shows: the overall verdict +// and one card per check. The cards are the typed input of the health_card +// partial of the component kit, so the stage-2 page renders them as they are. +func (h *Handlers) overviewBody() map[string]any { + c := h.healthChecks() + return map[string]any{ + "Overview": true, + "Hostname": c["Hostname"], + "OverallStatus": c["OverallStatus"], + "OverallHeading": c["OverallHeading"], + "Cards": healthCards(c), + } +} + +// healthCards turns the checks into the six cards of Overview, in the order of +// the tables on Health. Each card links to the table behind it. +func healthCards(c map[string]any) []view.HealthCard { + machine := c["Machine"].(health.Machine) + procs := c["Processes"].([]health.Process) + cert := c["Cert"].(health.Certificate) + sockets := c["Sockets"].([]health.Socket) + ptr := c["PTR"].(dnscheck.Result) + + card := func(name, icon, href string, status health.Status, value, sub string) view.HealthCard { + return view.HealthCard{Name: name, Icon: icon, Href: href, Level: cardLevel(status), Value: value, Sub: sub} + } + + cpu, ram := "No reading", "" + if machine.CPU.Measured { + cpu = "CPU " + machine.CPU.BusyText() + } + if machine.Memory.Measured { + ram = "RAM " + machine.Memory.PctText() + } + + running := 0 + for _, p := range procs { + if p.Status == health.StatusOK { + running++ + } + } + procValue := fmt.Sprintf("%d of %d running", running, len(procs)) + if c["ProcessError"].(bool) { + procValue = "Could not be read" + } + + certValue := cert.Detail + if cert.Status == health.StatusOK || cert.Status == health.StatusWarn { + certValue = fmt.Sprintf("Expires in %d days", cert.DaysLeft) + } + + queueValue := c["QueueSummary"].(string) + if e := c["QueueError"].(string); e != "" { + queueValue = "Could not be read" + } + + answering := 0 + for _, s := range sockets { + if s.Present { + answering++ + } + } + + ptrValue := "Forward = reverse" + if ptr.Status != health.StatusOK { + ptrValue = "Needs attention" + if ptr.Status == health.StatusUnknown { + ptrValue = "Not checked" + } + } + + return []view.HealthCard{ + card("Machine", "ti-cpu", "/server/health#machine", machine.Status, cpu, ram), + card("Processes", "ti-server-cog", "/server/health#processes", c["ProcessStatus"].(health.Status), procValue, ""), + card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject), + card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"), + card("Milter sockets", "ti-plug-connected", "/server/health#sockets", c["SocketStatus"].(health.Status), + fmt.Sprintf("%d of %d answering", answering, len(sockets)), ""), + card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)), + } +} + +// cardLevel maps the status of a check onto the three states of a card. A +// check that could not run is shown as a warning: the card must not look fine +// when nothing was verified. +func cardLevel(s health.Status) string { + switch s { + case health.StatusOK: + return "" + case health.StatusError: + return "fail" + default: + return "warn" + } +} + +// healthChecks runs every server check once. +func (h *Handlers) healthChecks() map[string]any { procs, procErr := health.Processes() procStatus := health.StatusUnknown if procErr != nil { diff --git a/internal/web/handlers/handlers_status_test.go b/internal/web/handlers/handlers_status_test.go new file mode 100644 index 0000000..db83eec --- /dev/null +++ b/internal/web/handlers/handlers_status_test.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "bytes" + "strings" + "testing" + + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/health" +) + +// checksFixture is one reading of every server check, shaped as healthChecks +// returns it. +func checksFixture() map[string]any { + return map[string]any{ + "Processes": []health.Process{ + {Name: "opendkim", State: "RUNNING", Status: health.StatusOK}, + {Name: "postfix", State: "FATAL", Status: health.StatusError}, + }, + "ProcessError": false, + "ProcessStatus": health.StatusError, + "QueueSummary": "Mail queue is empty", + "QueueError": "", + "QueueStatus": health.StatusOK, + "Machine": health.Machine{Status: health.StatusUnknown}, + "Cert": health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."}, + "Sockets": []health.Socket{ + {Name: "OpenDKIM", Present: true, Status: health.StatusOK}, + {Name: "send-log", Present: false, Status: health.StatusWarn}, + }, + "SocketStatus": health.StatusWarn, + "Hostname": "mail.example.org", + "PTR": dnscheck.Result{Status: health.StatusOK}, + "OverallStatus": health.StatusError, + "OverallHeading": "A component needs attention — see the details below.", + } +} + +// Overview is one verdict per check: six cards in the order of the tables on +// Health, each saying in words what its table would, and each leading there. +func TestHealthCards(t *testing.T) { + cards := healthCards(checksFixture()) + want := []struct{ name, level, value, href string }{ + {"Machine", "warn", "No reading", "/server/health#machine"}, + {"Processes", "fail", "1 of 2 running", "/server/health#processes"}, + {"TLS certificate", "warn", "Expires in 12 days", "/server/health#certificate"}, + {"Queue", "", "Mail queue is empty", "/outbound/queue"}, + {"Milter sockets", "warn", "1 of 2 answering", "/server/health#sockets"}, + {"Reverse DNS", "", "Forward = reverse", "/server/health#hostname"}, + } + if len(cards) != len(want) { + t.Fatalf("%d cards, want %d", len(cards), len(want)) + } + for i, w := range want { + c := cards[i] + if c.Name != w.name || c.Level != w.level || c.Value != w.value || c.Href != w.href || c.Icon == "" { + t.Errorf("card %d = %+v, want %+v", i, c, w) + } + } + + // A check that could not run must not look fine. + c := checksFixture() + c["ProcessError"] = true + c["ProcessStatus"] = health.StatusUnknown + c["PTR"] = dnscheck.Result{Status: health.StatusUnknown} + cards = healthCards(c) + if cards[1].Level != "warn" || cards[1].Value != "Could not be read" || cards[5].Level != "warn" || cards[5].Value != "Not checked" { + t.Errorf("unverified checks render as %+v and %+v", cards[1], cards[5]) + } +} + +// The two pages share the checks but not the content: Overview shows the +// verdicts and polls its own fragment; Health shows the tables, polls its own, +// and is the only place with the two server actions. +func TestOverviewAndHealthFragments(t *testing.T) { + h, _ := settingsServer(t) + render := func(data map[string]any) string { + t.Helper() + var buf bytes.Buffer + if err := h.view.Page("status").ExecuteTemplate(&buf, "content", data); err != nil { + t.Fatalf("render: %v", err) + } + return buf.String() + } + + full := checksFixture() + overview := render(map[string]any{ + "Overview": true, "OverallStatus": full["OverallStatus"], "OverallHeading": full["OverallHeading"], + "Cards": healthCards(full), + }) + for _, want := range []string{ + "

Overview

", `hx-get="/overview/fragment"`, `href="/server/health#certificate"`, + "Expires in 12 days", "1 of 2 running", `href="/server/health"`, + } { + if !strings.Contains(overview, want) { + t.Errorf("Overview is missing %q", want) + } + } + for _, gone := range []string{`action="/server/health/reload"`, `action="/server/health/recheck"`, `id="processes"`, "Health", `hx-get="/server/health/fragment"`, `action="/server/health/reload"`, + `action="/server/health/recheck"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="machine"`, + } { + if !strings.Contains(healthPage, want) { + t.Errorf("Health is missing %q", want) + } + } + if strings.Contains(healthPage, `hx-get="/overview/fragment"`) || strings.Contains(healthPage, `id="verdicts"`) { + t.Error("Health polls or shows the Overview fragment") + } +} diff --git a/internal/web/view/legacy_pages.txt b/internal/web/view/legacy_pages.txt index 4c9241d..3ca6077 100644 --- a/internal/web/view/legacy_pages.txt +++ b/internal/web/view/legacy_pages.txt @@ -10,7 +10,6 @@ # components.html, components.go, layout.html, GET /server/components # @routes the pre-2.0 route table is still in place (stage 1) # a page of the view engine that is still the old template -@routes backup dashboard deliveries diff --git a/internal/web/view/templates/status.html b/internal/web/view/templates/status.html index 6ae31e5..c3c0e42 100644 --- a/internal/web/view/templates/status.html +++ b/internal/web/view/templates/status.html @@ -3,8 +3,11 @@ {{define "wide"}}wide{{end}} {{define "content"}} +{{/* One legacy template for two pages until stage 2 gives each its own: + Overview (the verdicts) and Server › Health (the tables and the two + server actions). .Overview says which one this is. */}}
-

Status

+

{{if .Overview}}Overview{{else}}Health{{end}}

@@ -12,6 +15,7 @@ {{template "status_body" .}} +{{if not .Overview}}

Configuration

Regenerates the OpenDKIM and Postfix configuration from the @@ -24,3 +28,4 @@

{{end}} +{{end}} diff --git a/internal/web/view/templates/status_body.html b/internal/web/view/templates/status_body.html index b6222ee..04fb8a3 100644 --- a/internal/web/view/templates/status_body.html +++ b/internal/web/view/templates/status_body.html @@ -1,5 +1,29 @@ {{define "status_body"}} +{{if .Overview}} +{{/* Overview: one verdict per check, each leading to its table on Health. */}}
+
+

Overall {{.OverallStatus}}

+

{{.OverallHeading}} All details

+
+
+

Server health

+ + + + {{range .Cards}} + + + + + + {{end}} + +
CheckVerdictDetail
{{.Name}}{{.Value}}{{.Sub}}
+
+
+{{else}} +

Overall {{.OverallStatus}}

{{.OverallHeading}}

@@ -127,3 +151,4 @@
{{end}} +{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 237bc7f..f998a51 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -424,7 +424,7 @@ func TestStatusPageRendersEveryCheck(t *testing.T) { for _, want := range []string{ "opendkim", "FATAL", "Mail queue is empty", "mail.example.com", "203.0.113.10 → no PTR record", `action="/server/health/reload"`, - `hx-get="/overview/fragment"`, `class="st st-error"`, + `hx-get="/server/health/fragment"`, `class="st st-error"`, // Three .split rows inside the polled fragment: machine|processes, // queue|certificate, and sockets|hostname. Ids stay on the cards. `id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, diff --git a/internal/web/web.go b/internal/web/web.go index 7382d63..00bc65d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -184,8 +184,8 @@ func (s *Server) muxes() (public, authed *http.ServeMux) { // Overview. Its handler checks the role itself: the page is global-only // but does not live under /server/. - authed.HandleFunc("GET /overview", h.HandleStatus) - authed.HandleFunc("GET /overview/fragment", h.HandleStatusFragment) + authed.HandleFunc("GET /overview", h.HandleOverview) + authed.HandleFunc("GET /overview/fragment", h.HandleOverviewFragment) // Outbound: checked per domain in the handlers (lookupDomain, // lookupApplication), adding and deleting by role. @@ -246,7 +246,9 @@ func (s *Server) muxes() (public, authed *http.ServeMux) { } authed.HandleFunc(pattern, globalOnly(handler)) } - server("POST /server/health/recheck", h.HandleStatusRecheck) + server("GET /server/health", h.HandleHealth) + server("GET /server/health/fragment", h.HandleHealthFragment) + server("POST /server/health/recheck", h.HandleHealthRecheck) server("POST /server/health/reload", h.HandleReload) server("GET /server/log", h.HandleSystemLog) server("GET /server/log/fragment", h.HandleSystemLogBody)