panel-redesign: Overview, Health, Account and Server > Settings on the kit

Stage 2, step 2. Written by Sonnet, reviewed by Opus.

  - Four pages named after their mockups: overview, health, account,
    settings, each with a typed page struct (view/overview.go, health.go,
    account.go), a fixture and tests. The engine page "status" is gone
    (status.html, status_body.html deleted); "settings" is Server >
    Settings only. status and settings are off legacy_pages.txt.
  - Overview: the stamp and its sentence are derived from the six cards
    (the worst card sets the level and the lead names the cards that are
    not fine); the outbound and inbound domain tables are built from the
    existing service calls. Without the inbound relay the outbound box
    takes the full width.
  - Both pages refresh themselves as the old Status page did. Health
    polls its first row of boxes and swaps the second out of band.
    Overview polls its Server health box and the page head follows out of
    band (GET /overview/fragment), so stamp, sentence and time move with
    the cards.
  - Kit, attributes only (no class, no CSS, markup unchanged when empty):
    Box.Poll and Head.ID / Head.OOB.
  - healthCards: the Queue card says "Empty" / "N queued" instead of
    postqueue's line, which was clipped in a card; Processes and Milter
    sockets carry a sub line. OverallStatus / OverallHeading are gone.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged.

States the mockups do not draw, all from existing partials: errors and
successes as the kit flash above the boxes; Health with an unreadable
process list, a certificate without facts, a missing host name; Account
with the report authorization not published / not found / not checked
and with hosted reports off; empty domain tables as empty_state.

Differences from the mockups, for the owner:
  - Overview title is one of "Everything is running normally." /
    "Running, with warnings." / "A component needs attention.", not
    "Mail is flowing..." (the checks do not prove that); the note says
    "Updated HH:MM:SS UTC" without an interval, Health's lead says
    "Refreshes automatically" (panel.js polls at 5 s active, 30 s idle);
  - one Queue card ("outbound"), two milter sockets: what this build has;
  - the days column is the stats window, min(30, retention);
  - Health: no "check port 80" paragraph (the certificate's own detail
    says what to check); a failing socket shows its detail under the path;
    figures come from the sampler ("12%", "MiB");
  - Account: the DMARC form keeps the address field for "Another
    address" (hidden unless chosen); the count "N of M now" is the
    domains following this user's default;
  - Settings: Level 2 links the domain list only;
  - /help#checks and /help#dmarc open Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/overview.png, health.png,
    account.png, settings.png: 1440 px wide, full height, headless Edge,
    real templates served locally with their fixtures - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the four pages at 1440 and 1280: 612 texts
    checked, problems: [].
  - Polling was exercised in a browser against a local fixture server
    with document.hidden overridden (the pane was hidden): one element
    per id after a swap, no leftover hx-swap-oob, about one request per
    5 s. The first request of a visible tab was not observed.
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 00:11:00 +03:00
co-authored by Claude Opus 5.5
parent 5b979a0540
commit 5c654723c2
34 changed files with 1991 additions and 673 deletions
+13
View File
@@ -6,6 +6,7 @@ import (
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) {
@@ -32,6 +33,18 @@ func (h *Handlers) pageBase(r *http.Request) map[string]any {
}
}
// shellMeta is what the shell of a page of the component kit needs to know
// about the person looking at it: their name and which groups of the menu they
// reach. The page's constructor adds its title and where it sits in the menu.
func (h *Handlers) shellMeta(r *http.Request) view.Meta {
p, _ := h.principal(r)
user := auth.CurrentUser(r)
if user == "" {
user = p.Username
}
return view.Meta{User: user, IsGlobal: p.IsGlobal(), HasOutbound: p.HasOutbound(), HasInbound: p.HasInbound()}
}
func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
if p.IsGlobal() {
return h.store.ListDomains()
+31 -18
View File
@@ -10,6 +10,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
"golang.org/x/crypto/bcrypt"
)
@@ -61,19 +62,12 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
}
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
data := h.pageBase(r)
data["Title"] = "SelfPost — account"
data["Active"] = "account"
data["AccountPage"] = true
data["Role"] = u.Role
data["FormUsername"] = f.Username
data["FormEmail"] = f.Email
data["FormDMARCMode"] = f.DMARCMode
data["FormDMARCAddress"] = f.DMARCAddress
data["AccountEmail"] = u.Email
data["Error"] = f.Err
data["Flash"] = accountFlash(r)
data["DMARCIngestEnabled"] = h.dmarc != nil && h.cfg.DMARCEnabled
hosted := h.dmarc != nil && h.cfg.DMARCEnabled
page := view.NewAccount(h.shellMeta(r), string(u.Role), hosted, u.Email)
page.Username, page.Email = f.Username, f.Email
page.DMARCSelected, page.DMARCAddress = f.DMARCMode, f.DMARCAddress
page.WithResult(accountFlash(r), f.Err)
page.WithDomainUse(h.domainsFollowing(r, u))
// The stored default, not the one being typed: the authorization record
// belongs to the address domains are actually told to publish.
@@ -81,14 +75,33 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
if addr := def.Resolve(""); addr != "" && def.Mode != store.DMARCDefaultHosted {
if hub := dnscheck.EmailDomain(addr); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
data["ReportAuthDNS"] = h.dns.ReportAuth(ctx, hub)
ra := h.dns.ReportAuth(ctx, hub)
cancel()
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(hub)
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
data["ReportAuthHub"] = hub
page.WithAuthorization(dnscheck.ReportAuthRecordName(hub), dnscheck.ReportAuthExample(),
string(ra.Status), ra.Detail, ra.Records)
}
}
h.view.Render(w, status, "settings", data)
h.view.Render(w, status, "account", page)
}
// domainsFollowing counts the outbound domains the user reaches and how many of
// them take their report address from this user's default.
func (h *Handlers) domainsFollowing(r *http.Request, u store.User) (following, total int) {
p, ok := h.principal(r)
if !ok {
return 0, 0
}
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: account: list domains: %v", err)
return 0, 0
}
for _, d := range domains {
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
following++
}
}
return following, len(domains)
}
func accountFlash(r *http.Request) string {
@@ -170,3 +170,102 @@ func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
t.Errorf("another user's e-mail was changed to %q", admin.Email)
}
}
// Account is drawn by the shell of the kit, for the signed-in person, with
// their role in the kicker.
func TestAccountPageIsDrawnInTheShell(t *testing.T) {
h, _ := settingsServer(t)
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{
`class="navbar is-primary"`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`,
`id="dmarc"`, `href="/static/panel.css"`,
} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
}
}
if strings.Contains(out, "/static/legacy.css") {
t.Error("account page loads the old stylesheet")
}
}
// A refused form is shown again with the error above the boxes and what the
// person typed in the fields — escaped.
func TestAccountRefusalKeepsWhatWasTyped(t *testing.T) {
h, _ := settingsServer(t)
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {`o"><b>x</b>`}, "email": {"not-an-address"}})
out := rec.Body.String()
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /account/profile = %d, want 400:\n%s", rec.Code, out)
}
if !strings.Contains(out, `notification is-danger is-light`) {
t.Error("the refusal is not shown as the error flash")
}
if !strings.Contains(out, `value="not-an-address"`) {
t.Error("the e-mail that was typed is not shown again")
}
if strings.Contains(out, `<b>x</b>`) {
t.Errorf("the username was written back unescaped:\n%s", out)
}
rec = postFormAs(h.HandleAccountPassword, globalPrincipal, "/account/password", nil,
url.Values{"current_password": {"nope"}, "new_password": {"another-long-password-2"}, "new_password_confirm": {"another-long-password-2"}})
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "Current password is incorrect.") {
t.Errorf("wrong current password = %d:\n%s", rec.Code, rec.Body.String())
}
// A password is never written back.
if strings.Contains(rec.Body.String(), "another-long-password-2") {
t.Error("a submitted password is in the page that refused it")
}
}
func TestAccountResultsAreTheFlash(t *testing.T) {
h, _ := settingsServer(t)
for done, want := range map[string]string{
"profile": "Profile saved.",
"password": "Password changed.",
"dmarc": "Default report address saved.",
} {
out := getBody(t, h.HandleAccount, "/account?done="+done)
if !strings.Contains(out, `notification is-success is-light`) || !strings.Contains(out, want) {
t.Errorf("?done=%s: the result is not shown (%q)", done, want)
}
}
}
// A domain administrator has the same Account page in the same shell; it has
// no Server group in the menu and says its role.
func TestAccountPageForADomainAdministrator(t *testing.T) {
h, _ := settingsServer(t)
d, _ := h.store.AddDomain("example.com", "mail")
p := domainAdmin(t, h.store, "ops", d.ID)
out := getBodyAs(t, h.HandleAccount, "/account", p)
if !strings.Contains(out, `· domain`) || !strings.Contains(out, `action="/account/profile"`) {
t.Errorf("account page for a domain administrator:\n%s", out)
}
for _, gone := range []string{`href="/server/health"`, `href="/overview"`, `href="/server/users"`} {
if strings.Contains(out, gone) {
t.Errorf("a domain administrator's menu carries %s", gone)
}
}
}
// The default address needs an authorization record when it is on another
// domain; with "no reports" nothing is asked of DNS and no record is drawn. The
// count of following domains is the user's own.
func TestAccountReportAuthorizationFollowsTheStoredDefault(t *testing.T) {
h, _ := settingsServer(t)
d, _ := h.store.AddDomain("example.com", "mail")
h.followCreator(d.ID, globalPrincipal.ID)
if _, err := h.store.AddDomain("other.example", "mail"); err != nil {
t.Fatal(err)
}
out := getBody(t, h.HandleAccount, "/account")
if strings.Contains(out, "Report authorization") {
t.Error("an authorization record for a default of no reports")
}
if !strings.Contains(out, "1 of 2 now") {
t.Errorf("the domains following the default are not counted:\n%s", out)
}
}
@@ -0,0 +1,253 @@
package handlers
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
// healthReading is one plausible reading of every check Health shows, so a test
// can render the page and vary the one part it is about.
func healthReading() map[string]any {
c := checksFixture()
c["Processes"] = []health.Process{
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
}
c["Cert"] = health.Certificate{
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
NotAfter: time.Date(2026, 11, 8, 9, 14, 0, 0, time.UTC), DaysLeft: 30,
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
}
c["Machine"] = health.Machine{
CPU: health.CPU{
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
Status: health.StatusOK, Detail: "4 cores · 4 threads",
},
Memory: health.Memory{
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
},
Network: health.Network{
Measured: true, RxRate: 2048, TxRate: 1024, Status: health.StatusOK,
Interfaces: []health.Interface{{Name: "eth0", RxBytes: 1 << 20, TxBytes: 1 << 19, RxRate: 2048, TxRate: 1024, Measured: true}},
},
Window: 5 * time.Second,
Status: health.StatusOK,
}
c["Sockets"] = []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
{Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."},
}
c["SocketStatus"] = health.StatusWarn
c["Hostname"] = "mail.example.com"
c["PTR"] = dnscheck.Result{
Status: health.StatusError,
Detail: "No address has a reverse record.",
Records: []string{"203.0.113.10 → no PTR record"},
}
return c
}
func renderHealth(t *testing.T, h *Handlers, c map[string]any, flash string) string {
t.Helper()
rec := httptest.NewRecorder()
h.view.Render(rec, 200, "health", healthPage(view.Meta{User: "admin", IsGlobal: true}, c, flash))
if rec.Code != 200 {
t.Fatalf("render health: %d: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// Health shows every check of the reading, with the verdict of each box in its
// head, the anchors the Overview cards lead to, and the two server actions.
func TestHealthPageRendersEveryCheck(t *testing.T) {
h, _ := settingsServer(t)
out := renderHealth(t, h, healthReading(), "")
for _, want := range []string{
"opendkim", "fatal", "exited too quickly",
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC",
`action="/server/health/reload"`, `action="/server/health/recheck"`,
`hx-get="/server/health/fragment"`,
`<span class="tag is-danger is-light">error</span>`, // postfix, and the reverse DNS box
`id="processes"`, `id="machine"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
// The machine card: the bars carry their reading in an attribute (the
// CSP rules out sizing them with a style), and the figures are printed
// beside them for anything that does not render a bar.
`<progress class="progress is-small is-success mb-1" value="12"`, `value="50"`,
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
"eth0: 1.0 MiB in, 512.0 KiB out",
// A socket that is not fine says why; one that is does not repeat "Listening".
"send-log socket missing: the log stops being written.",
} {
if !strings.Contains(out, want) {
t.Errorf("health page is missing %q", want)
}
}
if strings.Contains(out, "Listening") {
t.Error("a socket that answers repeats its detail beside the tag")
}
// The reload control stays outside the polled rows.
body, conf := strings.Index(out, `id="health-machine"`), strings.Index(out, `id="configuration"`)
if body < 0 || conf < body {
t.Fatal("polled rows or the Configuration box missing or out of order")
}
if frag := out[body:conf]; strings.Contains(frag, `action="/server/health/reload"`) || !strings.Contains(frag, `id="hostname"`) {
t.Error("the hostname box is outside the polled rows, or the reload control inside them")
}
}
// A machine whose counters could not be read — no /proc, or a first reading
// with nothing to compare against — leaves its rows in place and blank, the
// same way an unreachable supervisord costs one line and not the page.
func TestHealthPageWithoutMachineMetrics(t *testing.T) {
h, _ := settingsServer(t)
c := healthReading()
c["Machine"] = health.Machine{
CPU: health.CPU{Status: health.StatusUnknown, Detail: "The kernel's processor counters (/proc/stat) could not be read here."},
Memory: health.Memory{Status: health.StatusUnknown, Detail: "The kernel's memory counters (/proc/meminfo) could not be read here."},
Network: health.Network{Status: health.StatusUnknown, Detail: "The kernel's network counters (/proc/net/dev) could not be read here."},
Status: health.StatusUnknown,
}
out := renderHealth(t, h, c, "")
if strings.Contains(out, "<progress") {
t.Error("a bar was drawn for a reading that does not exist")
}
for _, want := range []string{"/proc/stat", "/proc/meminfo", "/proc/net/dev", `<span class="tag is-light">unknown</span>`} {
if !strings.Contains(out, want) {
t.Errorf("degraded machine box is missing %q", want)
}
}
}
func TestHealthPageStatesOfTheOtherChecks(t *testing.T) {
h, _ := settingsServer(t)
c := healthReading()
c["ProcessError"] = true
c["ProcessStatus"] = health.StatusUnknown
c["Processes"] = []health.Process(nil)
c["Cert"] = health.Certificate{Status: health.StatusUnknown, Detail: "No certificate path is configured (TLS_CERT_FILE)."}
c["Hostname"] = ""
c["PTR"] = dnscheck.Result{Status: health.StatusUnknown}
out := renderHealth(t, h, c, "DNS re-checked.")
for _, want := range []string{
"Could not ask supervisord for the process list.",
"No certificate path is configured (TLS_CERT_FILE).",
"(SELFPOST_HOSTNAME is not set)",
`<div class="notification is-success is-light">`, "DNS re-checked.",
} {
if !strings.Contains(out, want) {
t.Errorf("health page is missing %q", want)
}
}
}
// The fragment is what the page holds in its polled rows, read again.
func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
h.cfg.Hostname = "mail.example.com"
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/server/health/fragment", nil)
h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
out := rec.Body.String()
if rec.Code != 200 {
t.Fatalf("GET /server/health/fragment = %d:\n%s", rec.Code, out)
}
for _, want := range []string{
`id="health-machine" data-poll`, `hx-get="/server/health/fragment"`,
`id="health-checks" hx-swap-oob="true"`, `id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
} {
if !strings.Contains(out, want) {
t.Errorf("health fragment is missing %q", want)
}
}
for _, gone := range []string{"<h1", "navbar", `action="/server/health/reload"`} {
if strings.Contains(out, gone) {
t.Errorf("health fragment carries %q", gone)
}
}
}
// Overview is one verdict per check, each leading to its table on Health, and
// the two kinds of domain; it shows none of the tables and none of the actions.
func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost")
h.cfg.Hostname = "mail.example.org"
d, err := h.store.AddDomain("example.org", "mail")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
if err := h.store.InsertQueued(store.SendLogEntry{QueueID: "Q1", Domain: d.Name, AppLogin: "app", From: "noreply@" + d.Name, To: "public@example.net", Subject: "Hello"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/overview", nil)
h.HandleOverview(rec, auth.RequestWithPrincipal(req, globalPrincipal))
out := rec.Body.String()
if rec.Code != 200 {
t.Fatalf("GET /overview = %d:\n%s", rec.Code, out)
}
for _, want := range []string{
`<h1 class="title is-3">`, `class="sp-postmark`, `<p class="sp-kicker">mail.example.org</p>`,
`href="/server/health#certificate"`, `href="/server/health#sockets"`, `href="/outbound/queue"`,
`<h2>Outbound domains</h2>`, `<a href="/outbound/domains/1"><strong>example.org</strong></a>`,
"DKIM", "SPF", "DMARC", "1\u00a0msg", `<th>30 days</th>`,
`href="/server/health"`,
`id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML"`,
} {
if !strings.Contains(out, want) {
t.Errorf("Overview is missing %q", want)
}
}
// The inbound relay has no service in this test, so its box is not drawn.
for _, gone := range []string{
`action="/server/health/reload"`, `action="/server/health/recheck"`, `id="processes"`, "<progress",
`hx-get="/server/health/fragment"`, `hx-swap-oob`, `Inbound domains`, `href="/inbound/domains"`,
} {
if strings.Contains(out, gone) {
t.Errorf("Overview carries %q — the tables and the actions live on Health", gone)
}
}
}
// The fragment of Overview is the Server health box and the page head, marked
// out of band; the page has the poll attributes on that box and no such mark.
func TestOverviewFragmentIsTheBoxAndTheHead(t *testing.T) {
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/overview/fragment", nil)
h.HandleOverviewFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
out := rec.Body.String()
if rec.Code != 200 {
t.Fatalf("GET /overview/fragment = %d:\n%s", rec.Code, out)
}
if got := strings.Count(out, `<span class="sp-h-name">`); got != 6 {
t.Errorf("the fragment holds %d cards, want 6", got)
}
for _, want := range []string{
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark`, "Updated ",
`id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML"`,
} {
if !strings.Contains(out, want) {
t.Errorf("the fragment is missing %q", want)
}
}
for _, gone := range []string{"<html", "navbar", "<table", "Outbound domains"} {
if strings.Contains(out, gone) {
t.Errorf("the fragment carries %q", gone)
}
}
}
+6 -10
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
// Server › Settings holds what is true of the whole instance, whoever is
@@ -30,18 +31,13 @@ func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request)
}
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
data := h.pageBase(r)
data["Title"] = "SelfPost — settings"
data["Active"] = "settings"
data["AccountPage"] = false
data["FormSendLogRetentionDays"] = formRetention
data["Error"] = formErr
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(formRetention), view.FormatRate(h.l1Messages(), h.l1Window()))
flash := ""
if r.URL.Query().Has("saved") {
data["Flash"] = "Settings saved."
flash = "Settings saved."
}
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data)
page.WithResult(flash, formErr)
h.view.Render(w, status, "settings", page)
}
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
@@ -21,10 +21,10 @@ func TestSettingsPageShowsSendLogRetention(t *testing.T) {
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`id="deliveries-retention"`,
`name="send_log_retention_days"`,
`value="45"`,
"Send log retention",
"Keep outbound log rows, days",
"Save settings",
`action="/server/settings"`,
`id="rate-limits"`,
} {
+216 -68
View File
@@ -3,44 +3,116 @@ package handlers
import (
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/store"
"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;
// card that leads to its detail, and lists the two kinds of domain;
// - 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.
// disagree.
// HandleOverview shows the verdicts.
// HandleOverview shows the verdicts and the domains.
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)
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: overview: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
retention := h.sendLogRetentionDays()
_, _, days := store.StatsWindow(retention, time.Now())
inboundOn := h.cfg.InboundEnabled && h.inbound != nil
page := view.NewOverview(h.shellMeta(r), h.cfg.Hostname, healthCards(h.healthChecks()), time.Now(), days, inboundOn)
page.OutboundRows = h.overviewDomains(domains, retention)
if inboundOn {
list, err := h.inbound.List()
if err != nil {
logf("panel: overview: list inbound domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for _, row := range h.inboundRows(list) {
page.InboundRows = append(page.InboundRows, view.OverviewInbound{
Name: row.Name, Href: fmt.Sprintf("/inbound/domains/%d", row.ID),
MX: view.Tag{Status: string(row.DNS), Label: "MX"}, Upstream: row.Upstream,
})
}
}
h.view.Render(w, http.StatusOK, "overview", page)
}
// HandleOverviewFragment is the polled part of Overview.
// HandleOverviewFragment is the polled part of Overview: the Server health box
// read again, and the page head — stamp, sentence, time — which follows the
// cards. The domain tables are not part of it; they are not a live reading.
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())
_, _, days := store.StatsWindow(h.sendLogRetentionDays(), time.Now())
page := view.NewOverview(h.shellMeta(r), h.cfg.Hostname, healthCards(h.healthChecks()), time.Now(), days, false)
h.view.RenderFragment(w, http.StatusOK, "overview_poll", page)
}
// overviewDomains is one row per sending domain: the verdict of each DNS check
// (cached for a few minutes, as on the domain list), the applications, and the
// mail it sent in the stats window.
func (h *Handlers) overviewDomains(domains []store.Domain, retention int) []view.OverviewDomain {
rows := make([]view.OverviewDomain, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = view.OverviewDomain{
Name: d.Name, Href: fmt.Sprintf("/outbound/domains/%d", d.ID), Apps: d.AppCount,
DNS: dnsTags(health.StatusUnknown, health.StatusUnknown, health.StatusUnknown), Messages: "—",
}
wg.Add(1)
go func() {
defer wg.Done()
if stats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt); err != nil {
logf("panel: overview: domain %d: send stats: %v", d.ID, err)
} else {
rows[i].Messages = view.FormatMessages(stats.Total)
}
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: overview: domain %d: dkim record: %v", d.ID, err)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: overview: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
}()
}
wg.Wait()
return rows
}
func dnsTags(dkim, spf, dmarc health.Status) []view.Tag {
return []view.Tag{
{Status: string(dkim), Label: "DKIM"},
{Status: string(spf), Label: "SPF"},
{Status: string(dmarc), Label: "DMARC"},
}
}
// HandleHealth shows every check in full, with the two server actions.
@@ -48,21 +120,18 @@ 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)
h.view.Render(w, http.StatusOK, "health", healthPage(h.shellMeta(r), h.healthChecks(), statusFlash(r)))
}
// HandleHealthFragment is the polled part of Health.
// HandleHealthFragment is the polled part of Health: the same two rows of
// boxes the page holds, read again.
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())
page := healthPage(h.shellMeta(r), h.healthChecks(), "")
page.Refresh = true
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
}
// HandleHealthRecheck re-runs the DNS checks of the server ignoring the cache.
@@ -74,18 +143,91 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
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),
// healthPage turns one reading of the checks (healthChecks) into the Health
// page: a table row per resource, process and socket, and the verdict of each
// box in its head.
func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
page := view.NewHealth(m, flash)
machine := c["Machine"].(health.Machine)
page.Machine.End = view.Verdict(string(machine.Status))
page.MachineRows = machineRows(machine)
page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status)))
page.ProcessError = c["ProcessError"].(bool)
for _, p := range c["Processes"].([]health.Process) {
page.ProcessRows = append(page.ProcessRows, view.ProcessRow{
Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail,
})
}
cert := c["Cert"].(health.Certificate)
page.Certificate.End = view.Verdict(string(cert.Status))
if !cert.NotAfter.IsZero() {
page.CertFacts = append(page.CertFacts, view.Fact{Label: "Expires", Value: view.Plain(cert.NotAfter.UTC().Format("2006-01-02 15:04 UTC")), Mono: true})
}
if cert.Subject != "" {
page.CertFacts = append(page.CertFacts, view.Fact{Label: "Names", Value: view.Plain(cert.Subject), Mono: true})
}
page.CertDetail, page.CertProblem = cert.Detail, cert.Status != health.StatusOK
page.Sockets.End = view.Verdict(string(c["SocketStatus"].(health.Status)))
for _, s := range c["Sockets"].([]health.Socket) {
row := view.SocketRow{Name: s.Name, Path: s.Path, State: view.Tag{Status: string(s.Status)}}
if s.Status != health.StatusOK {
row.Detail = s.Detail
}
page.SocketRows = append(page.SocketRows, row)
}
ptr := c["PTR"].(dnscheck.Result)
page.Hostname.End = view.Verdict(string(ptr.Status))
host := c["Hostname"].(string)
if host == "" {
host = "(SELFPOST_HOSTNAME is not set)"
}
page.HostFacts = []view.Fact{{Label: "Hostname", Value: view.Plain(host), Mono: true}}
if len(ptr.Records) > 0 {
var lookup view.Text
for i, rec := range ptr.Records {
if i > 0 {
lookup = append(lookup, view.Br())
}
lookup = append(lookup, view.Inline{Text: rec})
}
page.HostFacts = append(page.HostFacts, view.Fact{Label: "Lookup", Value: lookup, Mono: true})
}
return page
}
// machineRows is the Machine table: processor and memory as bars with their
// figure, the network as rates, and for each the sentence the sampler gave. A
// reading that could not be taken keeps its row, without a figure.
func machineRows(m health.Machine) []view.MachineRow {
cpu := view.MachineRow{Resource: "CPU", Detail: lines(m.CPU.Detail)}
if m.CPU.Measured {
cpu.Gauge = &view.Gauge{Percent: m.CPU.Percent(), Text: m.CPU.BusyText(), Level: cardLevel(m.CPU.Status)}
}
mem := view.MachineRow{Resource: "Memory", Detail: lines(m.Memory.Detail)}
if m.Memory.Measured {
mem.Gauge = &view.Gauge{Percent: m.Memory.Percent(), Text: m.Memory.PctText(), Level: cardLevel(m.Memory.Status)}
}
net := view.MachineRow{Resource: "Network"}
if m.Network.Measured {
net.Usage = "↓ " + m.Network.InRateText() + " · ↑ " + m.Network.OutRateText()
}
for _, i := range m.Network.Interfaces {
net.Detail = append(net.Detail, fmt.Sprintf("%s: %s in, %s out", i.Name, i.InText(), i.OutText()))
}
net.Detail = append(net.Detail, lines(m.Network.Detail)...)
return []view.MachineRow{cpu, mem, net}
}
func lines(s string) []string {
if s == "" {
return nil
}
return []string{s}
}
// healthCards turns the checks into the six cards of Overview, in the order of
@@ -109,15 +251,20 @@ func healthCards(c map[string]any) []view.HealthCard {
ram = "RAM " + machine.Memory.PctText()
}
running := 0
running, notRunning := 0, []string{}
for _, p := range procs {
if p.Status == health.StatusOK {
running++
} else {
notRunning = append(notRunning, p.Name)
}
}
procValue := fmt.Sprintf("%d of %d running", running, len(procs))
procValue, procSub := fmt.Sprintf("%d of %d running", running, len(procs)), "all programs"
if len(notRunning) > 0 {
procSub = strings.Join(notRunning, ", ")
}
if c["ProcessError"].(bool) {
procValue = "Could not be read"
procValue, procSub = "Could not be read", "supervisord"
}
certValue := cert.Detail
@@ -125,16 +272,17 @@ func healthCards(c map[string]any) []view.HealthCard {
certValue = fmt.Sprintf("Expires in %d days", cert.DaysLeft)
}
queueValue := c["QueueSummary"].(string)
queueValue := queueCardValue(c["QueueSummary"].(string))
if e := c["QueueError"].(string); e != "" {
queueValue = "Could not be read"
}
answering := 0
answering, socketNames := 0, make([]string, 0, len(sockets))
for _, s := range sockets {
if s.Present {
answering++
}
socketNames = append(socketNames, s.Name)
}
ptrValue := "Forward = reverse"
@@ -147,15 +295,31 @@ func healthCards(c map[string]any) []view.HealthCard {
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("Processes", "ti-server-cog", "/server/health#processes", c["ProcessStatus"].(health.Status), procValue, procSub),
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)), ""),
fmt.Sprintf("%d of %d answering", answering, len(sockets)), strings.Join(socketNames, ", ")),
card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)),
}
}
// queuedRequests reads the count out of postqueue's last line, "-- 3 Kbytes in 2
// Requests.".
var queuedRequests = regexp.MustCompile(`(\d+) Requests?`)
// queueCardValue is what the Queue card says in words short enough for a card:
// "Empty", "2 queued", or — for a line it does not recognise — the line itself.
func queueCardValue(summary string) string {
switch m := queuedRequests.FindStringSubmatch(summary); {
case summary == "" || strings.Contains(summary, "queue is empty"):
return "Empty"
case m != nil:
return m[1] + " queued"
}
return summary
}
// 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.
@@ -201,22 +365,19 @@ func (h *Handlers) healthChecks() map[string]any {
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),
"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,
}
}
@@ -230,19 +391,6 @@ func queueSummary(out string) string {
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") != "":
+22 -57
View File
@@ -1,8 +1,6 @@
package handlers
import (
"bytes"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dnscheck"
@@ -28,11 +26,9 @@ func checksFixture() map[string]any {
{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.",
"SocketStatus": health.StatusWarn,
"Hostname": "mail.example.org",
"PTR": dnscheck.Result{Status: health.StatusOK},
}
}
@@ -40,20 +36,20 @@ func checksFixture() map[string]any {
// 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"},
want := []struct{ name, level, value, sub, href string }{
{"Machine", "warn", "No reading", "", "/server/health#machine"},
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"},
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"},
{"Queue", "", "Empty", "outbound", "/outbound/queue"},
{"Milter sockets", "warn", "1 of 2 answering", "OpenDKIM, send-log", "/server/health#sockets"},
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/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 == "" {
if c.Name != w.name || c.Level != w.level || c.Value != w.value || c.Sub != w.sub || c.Href != w.href || c.Icon == "" {
t.Errorf("card %d = %+v, want %+v", i, c, w)
}
}
@@ -69,49 +65,18 @@ func TestHealthCards(t *testing.T) {
}
}
// 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"`,
// The Queue card has room for a few words: postqueue's own last line is cut
// down to what it says.
func TestQueueCardValue(t *testing.T) {
for in, want := range map[string]string{
"": "Empty",
"Mail queue is empty": "Empty",
"3 Kbytes in 2 Requests.": "2 queued",
"1 Kbytes in 1 Request.": "1 queued",
"something postfix invented": "something postfix invented",
} {
if !strings.Contains(overview, want) {
t.Errorf("Overview is missing %q", want)
if got := queueCardValue(in); got != want {
t.Errorf("queueCardValue(%q) = %q, want %q", in, got, 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")
}
}