Files
selfpost/internal/web/handlers/handlers_status.go
T
mixandClaude Opus 5.5 783dabbea5 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>
2026-10-09 22:07:10 +03:00

256 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 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.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)
}
// 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.healthChecks())
}
// 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, "/server/health?rechecked=1", http.StatusSeeOther)
}
// 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 {
logf("panel: status: supervisorctl: %v", procErr)
} else {
for _, p := range procs {
procStatus = health.Worst(procStatus, p.Status)
}
}
queueText, queueErr := readQueue()
queueStatus := health.StatusOK
if queueErr != "" {
queueStatus = health.StatusWarn
}
cert := health.CheckCertificate(h.cfg.TLSCertFile)
sockets := []health.Socket{
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
}
socketStatus := health.StatusUnknown
for _, sock := range sockets {
socketStatus = health.Worst(socketStatus, sock.Status)
}
machine := h.machine.Sample()
srv := h.dns.Server(h.cfg.Hostname, false)
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{
"Processes": procs,
"ProcessError": procErr != nil,
"ProcessStatus": procStatus,
"QueueSummary": queueSummary(queueText),
"QueueError": queueErr,
"QueueStatus": queueStatus,
"Machine": machine,
"Cert": cert,
"Sockets": sockets,
"SocketStatus": socketStatus,
"Hostname": h.cfg.Hostname,
"PTR": srv.PTR,
"OverallStatus": overall,
"OverallHeading": overallHeading(overall),
}
}
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
}
}
return ""
}
func overallHeading(worst health.Status) string {
switch worst {
case health.StatusError:
return "A component needs attention — see the details below."
case health.StatusWarn:
return "Running, with warnings below."
case health.StatusOK:
return "All components are running normally."
default:
return "Some checks could not be performed."
}
}
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}