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 <noreply@anthropic.com>
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
"<h1>Overview</h1>", `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"`, "<meter", `hx-get="/server/health/fragment"`} {
|
||||
if strings.Contains(overview, gone) {
|
||||
t.Errorf("Overview still carries %q — the tables and the actions live on Health", gone)
|
||||
}
|
||||
}
|
||||
|
||||
healthPage := render(full)
|
||||
for _, want := range []string{
|
||||
"<h1>Health</h1>", `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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user