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:
@@ -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
|
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
|
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`.
|
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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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] 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] 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**
|
- [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)
|
**Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| panel-notifications | E-mail notifications about important events | candidate | — | — |
|
| 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 | — | — |
|
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||||
| password-reset | Password reset by e-mail | 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) |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ var globalOnlyRoutes = []route{
|
|||||||
{"POST", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
|
{"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},
|
{"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", func(h *Handlers) http.HandlerFunc { return h.HandleOverview }, nil},
|
||||||
{"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
|
{"GET", "/server/health", func(h *Handlers) http.HandlerFunc { return h.HandleHealth }, nil},
|
||||||
{"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, 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", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
|
||||||
{"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, 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
|
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) {
|
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||||
|
|||||||
@@ -1,42 +1,177 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
"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 {
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := h.statusBody()
|
data := h.overviewBody()
|
||||||
data["Title"] = "SelfPost — status"
|
data["Title"] = "SelfPost — overview"
|
||||||
data["User"] = auth.CurrentUser(r)
|
data["User"] = auth.CurrentUser(r)
|
||||||
data["Active"] = "status"
|
data["Active"] = "status"
|
||||||
data["IsGlobal"] = true
|
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)
|
data["Flash"] = statusFlash(r)
|
||||||
h.view.Render(w, http.StatusOK, "status", data)
|
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 {
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
return
|
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 {
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.dns.Server(h.cfg.Hostname, true)
|
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()
|
procs, procErr := health.Processes()
|
||||||
procStatus := health.StatusUnknown
|
procStatus := health.StatusUnknown
|
||||||
if procErr != nil {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
# components.html, components.go, layout.html, GET /server/components
|
# components.html, components.go, layout.html, GET /server/components
|
||||||
# @routes the pre-2.0 route table is still in place (stage 1)
|
# @routes the pre-2.0 route table is still in place (stage 1)
|
||||||
# <name> a page of the view engine that is still the old template
|
# <name> a page of the view engine that is still the old template
|
||||||
@routes
|
|
||||||
backup
|
backup
|
||||||
dashboard
|
dashboard
|
||||||
deliveries
|
deliveries
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
{{define "wide"}}wide{{end}}
|
{{define "wide"}}wide{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{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. */}}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>Status</h1>
|
<h1>{{if .Overview}}Overview{{else}}Health{{end}}</h1>
|
||||||
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@
|
|||||||
|
|
||||||
{{template "status_body" .}}
|
{{template "status_body" .}}
|
||||||
|
|
||||||
|
{{if not .Overview}}
|
||||||
<div class="card" id="configuration">
|
<div class="card" id="configuration">
|
||||||
<h2>Configuration</h2>
|
<h2>Configuration</h2>
|
||||||
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||||
@@ -24,3 +28,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
{{define "status_body"}}
|
{{define "status_body"}}
|
||||||
|
{{if .Overview}}
|
||||||
|
{{/* Overview: one verdict per check, each leading to its table on Health. */}}
|
||||||
<div id="status-body" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">
|
<div id="status-body" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||||
|
<div class="card" id="overall">
|
||||||
|
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||||
|
<p class="muted">{{.OverallHeading}} <a href="/server/health">All details</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="verdicts">
|
||||||
|
<h2>Server health</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Check</th><th>Verdict</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Cards}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{.Href}}">{{.Name}}</a></td>
|
||||||
|
<td><span class="st st-{{if eq .Level "fail"}}error{{else if eq .Level "warn"}}warn{{else}}ok{{end}}">{{.Value}}</span></td>
|
||||||
|
<td class="muted">{{.Sub}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div id="status-body" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||||
<div class="card" id="overall">
|
<div class="card" id="overall">
|
||||||
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||||
<p class="muted">{{.OverallHeading}}</p>
|
<p class="muted">{{.OverallHeading}}</p>
|
||||||
@@ -127,3 +151,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||||
"203.0.113.10 → no PTR record", `action="/server/health/reload"`,
|
"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,
|
// Three .split rows inside the polled fragment: machine|processes,
|
||||||
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
||||||
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
||||||
|
|||||||
+5
-3
@@ -184,8 +184,8 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
|||||||
|
|
||||||
// Overview. Its handler checks the role itself: the page is global-only
|
// Overview. Its handler checks the role itself: the page is global-only
|
||||||
// but does not live under /server/.
|
// but does not live under /server/.
|
||||||
authed.HandleFunc("GET /overview", h.HandleStatus)
|
authed.HandleFunc("GET /overview", h.HandleOverview)
|
||||||
authed.HandleFunc("GET /overview/fragment", h.HandleStatusFragment)
|
authed.HandleFunc("GET /overview/fragment", h.HandleOverviewFragment)
|
||||||
|
|
||||||
// Outbound: checked per domain in the handlers (lookupDomain,
|
// Outbound: checked per domain in the handlers (lookupDomain,
|
||||||
// lookupApplication), adding and deleting by role.
|
// lookupApplication), adding and deleting by role.
|
||||||
@@ -246,7 +246,9 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
authed.HandleFunc(pattern, globalOnly(handler))
|
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("POST /server/health/reload", h.HandleReload)
|
||||||
server("GET /server/log", h.HandleSystemLog)
|
server("GET /server/log", h.HandleSystemLog)
|
||||||
server("GET /server/log/fragment", h.HandleSystemLogBody)
|
server("GET /server/log/fragment", h.HandleSystemLogBody)
|
||||||
|
|||||||
Reference in New Issue
Block a user