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:
mix
2026-10-09 22:07:10 +03:00
co-authored by Claude Opus 5.5
parent b6feac2200
commit 783dabbea5
12 changed files with 311 additions and 20 deletions
+143 -8
View File
@@ -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 {