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
+11
View File
@@ -76,6 +76,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the pointer to the one-time setup link, in the same column as the form. The
signed-out column is sized through `.sp-form`, not through `form` (owner,
2026-10-09).
- panel-redesign (design): the Notifications boxes are out of the Account
and Server › Settings mockups. Event notifications are not built
(panel-notifications is a candidate), and 2.0 carries no box for a feature
that does not exist; they are drawn again as the first step of that
feature (owner, 2026-10-10).
- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account**
and **Server › Settings** are in the new design. Overview shows the verdict
as a stamp with one sentence, the six checks as cards and both domain
lists; Health holds the tables behind the cards. Both refresh themselves.
The Queue card reads "Empty" or "N queued". `status` and `settings` are
off the ratchet.
### Changed
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+4 -4
View File
@@ -176,8 +176,8 @@ list, an action under the thing it changes.
| system-log | `GET /server/log` | `/system-log` |
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits; plus the notifications switch |
| account | `GET /account` · `POST /account/profile`, `/password`, `/notifications`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits |
| account | `GET /account` · `POST /account/profile`, `/password`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — |
Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`,
@@ -232,7 +232,7 @@ the first step of that feature's plan — not invented during implementation.
| inbound-antispam-panel (agreed, `2.1.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` |
| inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` |
| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | `account`, `settings` |
| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | **no** — drawn once, taken out of `account` and `settings` on 2026-10-10 (owner): 2.0 ships no box for a feature that is not built; redrawn as the first step of its plan |
| password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** |
| delivery-log-storage (candidate) | the *Delivery log* box of **Outbound › Log › message**, filled from a table instead of a grep of today's `mail.log` | `log_pane`, unchanged | `out-message` |
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
@@ -514,7 +514,7 @@ only when both are done. Every stage-2 step ends with the evidence of
**Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs)
- [x] Signed-out pages: login, setup — **Sonnet**
- [ ] Overview, Health, and the Account / Settings split — **Sonnet**
- [x] Overview, Health, and the Account / Settings split — **Sonnet**
- [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
+1 -1
View File
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| 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 | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 16/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 17/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) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
+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")
}
}
+215
View File
@@ -0,0 +1,215 @@
package view
// The data of Account (templates/account.html, mockup account.html) and of
// Server › Settings (templates/settings.html, mockup settings.html): what used
// to be one Settings page, split by whose it is. Account is the signed-in
// user's own — who they are to the panel, their password and where DMARC reports
// about their domains go — and every role has it. Settings is the instance's.
import (
"strconv"
"strings"
)
// Account is the data of the Account page. Three forms, each its own POST: the
// profile (Username, Email), the password, and the default report address
// (DMARCSelected, DMARCAddress). The form fields hold what was stored, or what
// was just submitted when a form is shown again with an error.
type Account struct {
Meta
Head Head
Flash *Flash
Profile Box
Username string
Email string
Password Box
DMARC Box
DMARCOptions []Option
DMARCSelected string
DMARCAddress string
DMARCHelp Text
hosted bool
// Authorization is the report-authorization record the default address
// needs when it is on another domain than the senders; nil when it needs
// none (the hosted address, no reports, an address on the senders' domain).
Authorization *Record
}
// DMARC default choices, the values of the select (store.DMARCDefault*).
const (
DMARCChoiceHosted = "hosted"
DMARCChoiceAccount = "account"
DMARCChoiceCustom = "custom"
DMARCChoiceNone = "none"
)
// NewAccount builds the page for a user. role is "global" or "domain" and
// appears in the kicker. hosted says whether SelfPost-hosted reports are
// offered (report ingest is on), accountEmail is the e-mail the "my account
// e-mail" choice names.
func NewAccount(m Meta, role string, hosted bool, accountEmail string) *Account {
m.Title, m.Section, m.Page = "Account", "user", "account"
a := &Account{
Meta: m,
Head: Head{
Kicker: "Signed in as " + m.User + " · " + role,
Title: "Account",
Lead: Plain("Who you are to this panel and where reports about your domains go. Applications keep their own logins and passwords and are not affected."),
},
Profile: Box{No: "01", Title: "Profile"},
Password: Box{No: "02", Title: "Password"},
DMARC: Box{No: "03", Title: "DMARC reports", ID: "dmarc",
Help: &HelpLink{Href: "/help#dmarc", Title: "What the reports are and where they go"}},
hosted: hosted,
}
a.WithDomainUse(0, 0)
if hosted {
a.DMARCOptions = append(a.DMARCOptions, Option{Value: DMARCChoiceHosted, Label: "SelfPost hosted — read them under DMARC reports"})
}
account := "My account e-mail"
if accountEmail != "" {
account += " — " + accountEmail
}
a.DMARCOptions = append(a.DMARCOptions,
Option{Value: DMARCChoiceAccount, Label: account},
Option{Value: DMARCChoiceCustom, Label: "Another address…"},
Option{Value: DMARCChoiceNone, Label: "No reports"},
)
return a
}
// WithDomainUse says how many of the user's outbound domains follow their
// default address now (of total); nothing is said when they have none.
func (a *Account) WithDomainUse(following, total int) *Account {
help := Rich("Used by every outbound domain whose report address is set to ", Em(a.Meta.User+"'s default"))
if total > 0 {
help = append(help, Inline{Text: " — " + strconv.Itoa(following) + " of " + strconv.Itoa(total) + " now"})
}
help = append(help, Inline{Text: ". Changing it changes the DMARC record those domains must publish. "})
if a.hosted {
help = append(help, Inline{Text: "The hosted address is parsed by SelfPost; any other just receives the raw XML."})
} else {
help = append(help, Inline{Text: "The address receives the raw XML."})
}
a.DMARCHelp = help
return a
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the first box.
func (a *Account) WithResult(flash, formErr string) *Account {
switch {
case formErr != "":
a.Flash = &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
a.Flash = &Flash{Text: Plain(flash)}
}
return a
}
// WithAuthorization adds the report-authorization record the default address
// needs on its own domain: where to publish it (host, value) and what DNS said
// about it — a status ("ok", "warn", "error" or "unknown"), the sentence that
// explains it and the records found. The tag in the box head repeats the
// verdict.
func (a *Account) WithAuthorization(host, value, status, detail string, found []string) *Account {
rec := &Record{
Name: "Report authorization",
Note: Plain("Needed because the address is on another domain than the senders"),
Host: host, Type: "TXT", Value: value,
}
switch status {
case "ok":
rec.Status = Tag{Status: "published"}
a.DMARC.End = Rich(TagOf("ok", "authorized"))
case "warn", "error":
level := map[string]string{"warn": "warn", "error": "fail"}[status]
rec.Status = Tag{Status: level, Label: "not published"}
if len(found) > 0 {
rec.InDNS = strings.Join(found, " ")
rec.Problem = Plain(detail)
} else {
rec.Note = Plain(detail)
}
a.DMARC.End = Rich(TagOf(level, "not authorized"))
case "unknown":
rec.Status = Tag{Status: "unknown", Label: "not checked"}
rec.Note = Plain(detail)
a.DMARC.End = Rich(TagOf("unknown", "not checked"))
}
a.Authorization = rec
return a
}
// Settings is the data of Server › Settings: one form with the instance's
// settings and one Save settings button, and the read-only level-1 rate limit
// the operator sets in .env.
type Settings struct {
Meta
Head Head
Flash *Flash
Retention Box
RetentionDays string
RateLimits Box
RateFacts []Fact
}
// NewSettings builds the page. retention is what the field shows (the stored
// value, or what was just submitted); level1 is the per-client-IP limit as
// FormatRate writes it.
func NewSettings(m Meta, retention, level1 string) *Settings {
m.Title, m.Section, m.Page = "Settings", "server", "settings"
return &Settings{
Meta: m,
Head: Head{
Kicker: "Server",
Title: "Settings",
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under ",
Link("/account", "Account"), "."),
},
Retention: Box{No: "01", Title: "Log retention"},
RetentionDays: retention,
RateLimits: Box{No: "02", Title: "Sending rate limits", ID: "rate-limits", End: Plain("Read-only")},
RateFacts: []Fact{
{Label: "Level 1 · per client IP", Value: Plain(level1), Big: true,
Note: Rich("Set in ", Code(".env"), "; restart to change. Nothing below may exceed it.")},
{Label: "Level 2 · domain, application",
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
},
}
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the form.
func (s *Settings) WithResult(flash, formErr string) *Settings {
switch {
case formErr != "":
s.Flash = &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
s.Flash = &Flash{Text: Plain(flash)}
}
return s
}
// FormatRate writes a rate limit of messages per window the short way:
// "600 / h" for an hourly limit, "30 / min", "5 000 / day", and the window in
// seconds for any other.
func FormatRate(messages, windowSeconds int) string {
n := FormatCount(int64(messages))
switch {
case windowSeconds == 60:
return n + " / min"
case windowSeconds == 3600:
return n + " / h"
case windowSeconds == 86400:
return n + " / day"
case windowSeconds > 0 && windowSeconds%3600 == 0:
return n + " / " + strconv.Itoa(windowSeconds/3600) + " h"
}
return n + " / " + strconv.Itoa(windowSeconds) + " s"
}
+11
View File
@@ -168,6 +168,12 @@ type Head struct {
Route Route
Note []string // right-hand note, one line each
Actions []Action
// ID and OOB make the head replaceable by a polled fragment: ID is the
// element's id, and OOB (set on the fragment's copy only, never on the page)
// marks it to be swapped into the page by that id. Used by Overview, whose
// stamp and sentence follow its cards. Both are attributes, not markup.
ID string
OOB bool
}
// Crumb is one step of a Head's trail; a step without Href is plain text (the
@@ -220,6 +226,10 @@ type Flash struct {
// the slot at the right of the head (a note, a tag, a link) and Filter takes it
// instead as a filter form. Help links the section's topic on the Help page.
// ID is the anchor a side menu points at. Variant is BoxDanger or BoxCredential.
// Poll, when set, makes the box the element that polls: it is the address of the
// fragment that answers with the box again (panel.js schedules the next request
// by the box's ID, which Poll therefore requires). Used by Overview's Server
// health box. It adds attributes only, no markup.
type Box struct {
No string
Icon string
@@ -229,6 +239,7 @@ type Box struct {
Filter *Filter
Help *HelpLink
Variant string
Poll string
}
// HelpLink is a Box's link to its Help topic.
+25
View File
@@ -583,3 +583,28 @@ func TestLegacyPagesStayOnTheOldLayout(t *testing.T) {
mustContain(t, out, `href="/static/legacy.css"`, `class="shell"`)
mustNotContain(t, out, "bulma.min.css", "tabler-icons.css", "/static/panel.css", "navbar")
}
// Poll, ID and OOB are optional attributes: empty, box_open and page_head write
// exactly the markup they wrote before they existed.
func TestPollAndOOBAttributesAreAbsentWhenEmpty(t *testing.T) {
if got := partial(t, "box_open", Box{No: "01", Title: "x", ID: "apps"}); !strings.HasPrefix(got, `<div class="box" id="apps">`+"\n") {
t.Errorf("box_open without Poll starts %q", got[:40])
}
if got := partial(t, "box_open", Box{No: "01", Title: "x"}); !strings.HasPrefix(got, `<div class="box">`+"\n") {
t.Errorf("box_open without ID or Poll starts %q", got[:40])
}
if got := partial(t, "page_head", Head{Title: "T"}); !strings.HasPrefix(got, `<div class="sp-head">`+"\n") {
t.Errorf("page_head without ID starts %q", got[:40])
}
mustNotContain(t, partial(t, "box_open", Box{No: "01", Title: "x", ID: "a"}), "data-poll", "hx-")
mustNotContain(t, partial(t, "page_head", Head{Title: "T", ID: "h"}), "hx-swap-oob")
}
// With them, the box is the element that polls and the head is the element a
// fragment swaps into place — attributes on the element, nothing else changed.
func TestPollAndOOBAttributes(t *testing.T) {
got := partial(t, "box_open", Box{No: "01", Title: "x", ID: "health", Poll: "/overview/fragment"})
mustContain(t, got, `<div class="box" id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">`)
got = partial(t, "page_head", Head{Title: "T", ID: "h", OOB: true})
mustContain(t, got, `<div class="sp-head" id="h" hx-swap-oob="true">`)
}
+92
View File
@@ -1,5 +1,10 @@
package view
import (
"strconv"
"time"
)
// pageFixtures is the data each redesigned page is rendered with by the guard
// tests (guard_panel_test.go): one entry per page that has left
// legacy_pages.txt, keyed by the page's engine name. The guards render the page
@@ -18,4 +23,91 @@ var pageFixtures = map[string]func() any{
"components": func() any { return KitPage() },
"login": func() any { return NewLogin("mail.example.org", "") },
"setup": func() any { return NewSetup("0123456789abcdef", "") },
"overview": func() any { return overviewFixture() },
"health": func() any { return healthFixture(false) },
"account": func() any { return accountFixture() },
"settings": func() any { return settingsFixture() },
}
// admin is the signed-in global administrator every fixture is rendered for.
func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
// overviewFixture is the mockup's Overview: one certificate warning among six
// checks, three outbound domains and two inbound.
func overviewFixture() *Overview {
cards := []HealthCard{
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health#machine"},
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health#processes"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn},
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health#sockets"},
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health#hostname"},
}
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
dns := func(dkim, spf, dmarc string) []Tag {
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
}
o.OutboundRows = []OverviewDomain{
{Name: "example.org", Href: "/outbound/domains/1", DNS: dns("ok", "error", "ok"), Apps: 2, Messages: FormatMessages(1284)},
{Name: "shop.example.org", Href: "/outbound/domains/2", DNS: dns("ok", "ok", "ok"), Apps: 1, Messages: FormatMessages(18920)},
{Name: "notify.acme.io", Href: "/outbound/domains/3", DNS: dns("ok", "ok", "warn"), Apps: 3, Messages: FormatMessages(402)},
}
o.InboundRows = []OverviewInbound{
{Name: "lists.example.org", Href: "/inbound/domains/1", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "mx.internal:25"},
{Name: "acme.io", Href: "/inbound/domains/2", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "10.0.4.12:2525"},
}
return o
}
// healthFixture is the mockup's Health: a healthy machine and five processes,
// a certificate close to expiry, the two milter sockets and a good reverse
// record. refresh renders it as the polled fragment.
func healthFixture(refresh bool) *Health {
h := NewHealth(admin(), "")
h.Refresh = refresh
h.Machine.End = Verdict("ok")
h.MachineRows = []MachineRow{
{Resource: "CPU", Gauge: &Gauge{Percent: 12, Text: "12%", Level: LevelOK}, Detail: []string{"2 cores · 2 threads"}},
{Resource: "Memory", Gauge: &Gauge{Percent: 41, Text: "41%", Level: LevelOK}, Detail: []string{"812.0 MiB used of 1.9 GiB"}},
{Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}},
}
h.Processes.End = Verdict("ok")
for i, name := range []string{"postfix", "opendkim", "journal-milter", "panel", "dmarc-ingest"} {
h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"},
Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"})
}
h.Certificate.End = Verdict("warn")
h.CertFacts = []Fact{
{Label: "Expires", Value: Plain("2026-10-21 09:14 UTC"), Mono: true},
{Label: "Names", Value: Plain("mail.example.org"), Mono: true},
}
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
h.Sockets.End = Verdict("ok")
h.SocketRows = []SocketRow{
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}},
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
}
h.Hostname.End = Verdict("ok")
h.HostFacts = []Fact{
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
}
return h
}
// accountFixture is the mockup's Account: the global administrator, their
// e-mail as the DMARC default, and the authorization record that default needs.
func accountFixture() *Account {
m := admin()
a := NewAccount(m, "global", true, "mix@example.org")
a.Username, a.Email = "admin", "mix@example.org"
a.DMARCSelected = DMARCChoiceAccount
a.WithDomainUse(2, 3)
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
}
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
// messages an hour.
func settingsFixture() *Settings {
return NewSettings(admin(), "30", FormatRate(600, 3600))
}
+118
View File
@@ -0,0 +1,118 @@
package view
// The data of Server › Health (templates/health.html, mockup health.html):
// every check behind the cards of Overview, as tables, and the home of the two
// server actions, Re-check DNS and Reload configuration.
//
// Two rows of the page are polled (templates/health_body.html): the handler
// builds the same Health for the page and for its fragment, and the fragment
// carries Refresh so that its second row is swapped in out of band.
// Health is the data of the Health page.
type Health struct {
Meta
Head Head
Flash *Flash
// Refresh is set on the polled fragment only. The fragment answers with two
// sibling rows; the first replaces the element that polls, and the second
// is marked to be swapped into place by its id.
Refresh bool
Machine Box
MachineRows []MachineRow
Processes Box
ProcessRows []ProcessRow
ProcessError bool
Certificate Box
CertFacts []Fact
CertDetail string
CertProblem bool
Sockets Box
SocketRows []SocketRow
Hostname Box
HostFacts []Fact
Configuration Box
Reload Action
}
// MachineRow is one line of the Machine table. A row that is a level (CPU,
// memory) carries a Gauge; one that is a rate (network) carries Usage text; a
// reading that could not be taken carries neither and shows a dash. Detail is
// the supporting text, one line each.
type MachineRow struct {
Resource string
Gauge *Gauge
Usage string
Detail []string
}
// Gauge is a reading shown as a bar and its figure: Percent fills the bar, Text
// is the figure printed beside it, and Level (ok, warn or fail) colours it.
type Gauge struct {
Percent int
Text string
Level string
}
// Bar is the Bulma class that colours the gauge's progress bar.
func (g Gauge) Bar() string {
switch g.Level {
case LevelFail:
return "is-danger"
case LevelWarn:
return "is-warning"
}
return "is-success"
}
// ProcessRow is one supervised program: its name, its state as a tag and what
// supervisord said about it.
type ProcessRow struct {
Name string
State Tag
Detail string
}
// SocketRow is one milter socket. Note qualifies the name ("inbound spam
// filter"); Detail is shown only when the socket is not fine, since "Listening"
// says nothing the tag does not.
type SocketRow struct {
Name string
Note string
Path string
State Tag
Detail string
}
// NewHealth builds the page around its content. The handler fills the tables;
// the boxes, the head and the reload action are the page's own.
func NewHealth(m Meta, flash string) *Health {
m.Title, m.Section, m.Page = "Health", "server", "health"
h := &Health{
Meta: m,
Head: Head{
Kicker: "Server",
Title: "Health",
Lead: Plain("Everything behind the cards on Overview. Refreshes automatically."),
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
},
Machine: Box{No: "01", Title: "Machine", ID: "machine"},
Processes: Box{No: "02", Title: "Processes", ID: "processes"},
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate"},
Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"},
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname"},
Configuration: Box{No: "06", Title: "Configuration", ID: "configuration",
End: Plain("Safe to run at any time")},
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
}
if flash != "" {
h.Flash = &Flash{Text: Plain(flash)}
}
return h
}
// Verdict puts a check's status in the end slot of its box, as the mockup does.
func Verdict(status string) Text { return Text{TagOf(status, "")} }
-2
View File
@@ -24,8 +24,6 @@ inbound
inbound_delete
inbound_domain
mail_queue
settings
status
system_log
user_delete
user_form
+152
View File
@@ -0,0 +1,152 @@
package view
// The data of Overview (templates/overview.html, mockup overview.html): the
// answer to "is mail flowing?" in a postmark and six cards, and under them the
// two kinds of domain at a glance. Everything behind the cards is on Health
// (health.go).
import (
"fmt"
"strconv"
"strings"
"time"
)
// Overview is the data of the Overview page. Cards are the checks of Health
// reduced to a verdict each; the head's postmark and sentence are derived from
// them by NewOverview, so the stamp and the cards cannot disagree.
//
// The inbound box is drawn only when the inbound relay is on (InboundOn):
// without it the outbound box takes the whole width and nothing on the page
// points at /inbound/domains.
type Overview struct {
Meta
Head Head
Cards []HealthCard
Health Box
Outbound Box
Days int
OutboundRows []OverviewDomain
OutboundEmpty EmptyState
InboundOn bool
Inbound Box
InboundRows []OverviewInbound
InboundEmpty EmptyState
}
// OverviewDomain is one row of the Outbound domains table: the domain, the
// verdict of each DNS check as a tag (DKIM, SPF, DMARC), how many applications
// it has and how much mail it sent in the window (see FormatMessages).
type OverviewDomain struct {
Name string
Href string
DNS []Tag
Apps int
Messages string
}
// OverviewInbound is one row of the Inbound domains table: the domain, its MX
// check as a tag and the upstream it forwards to.
type OverviewInbound struct {
Name string
Href string
MX Tag
Upstream string
}
// NewOverview builds the page. host is the server's name (the kicker) and may
// be empty; updated is when the checks were read; days is the length of the
// send window of the last column.
func NewOverview(m Meta, host string, cards []HealthCard, updated time.Time, days int, inboundOn bool) *Overview {
m.Title, m.Section, m.Page = "Overview", "overview", ""
o := &Overview{
Meta: m,
Cards: cards,
Days: days,
Head: overviewHead(host, cards, updated),
Health: Box{No: "01", Title: "Server health", ID: "health", Poll: "/overview/fragment",
End: Rich(Link("/server/health", "All details")),
Help: &HelpLink{Href: "/help#checks", Title: "What these checks mean"}},
Outbound: Box{No: "02", Title: "Outbound domains", End: Rich(Link("/outbound/domains", "Manage"))},
OutboundEmpty: EmptyState{Icon: "ti-world-upload",
Text: Plain("No outbound domains yet. Add the first one under Outbound.")},
InboundOn: inboundOn,
Inbound: Box{No: "03", Title: "Inbound domains", End: Rich(Link("/inbound/domains", "Manage"))},
InboundEmpty: EmptyState{Icon: "ti-world-download",
Text: Plain("No inbound domains yet. Mail is not accepted for any.")},
}
return o
}
// HeadOOB is the head as the polled fragment carries it: the same head, marked
// to be swapped into the page by its id. The page itself never has the mark.
func (o *Overview) HeadOOB() Head {
h := o.Head
h.OOB = true
return h
}
// overviewHead is the stamp and the sentence under it. The stamp takes the
// worst card, counts the cards that are not fine, and the lead names them with
// their detail so the operator sees what to open without scanning the grid. A
// card that could not be read is a warning, never a pass (cardLevel).
func overviewHead(host string, cards []HealthCard, updated time.Time) Head {
level, bad := LevelOK, []HealthCard{}
for _, c := range cards {
if c.Level == "" {
continue
}
bad = append(bad, c)
if c.Level == LevelFail || level == LevelOK {
level = c.Level
}
}
title, word, count := "Everything is running normally.", "OK", len(cards)
switch level {
case LevelWarn:
title, word, count = "Running, with warnings.", "WARN", len(bad)
case LevelFail:
title, word, count = "A component needs attention.", "FAIL", len(bad)
}
h := Head{
Postmark: &Postmark{Top: "SELFPOST", Word: word, Bottom: fmt.Sprintf("%d OF %d CHECKS", count, len(cards)), Level: level},
Kicker: host,
Title: title,
ID: "overview-head",
Note: []string{"Updated " + updated.UTC().Format("15:04:05") + " UTC"},
}
if len(bad) > 0 {
lead := Text{{Text: "Needs attention: "}}
for i, c := range bad {
if i > 0 {
lead = append(lead, Inline{Text: ", "})
}
lead = append(lead, Link(c.Href, c.Name), Inline{Text: " (" + c.Value + ")"})
}
h.Lead = append(lead, Inline{Text: "."})
}
return h
}
// FormatCount writes a whole number with its thousands separated by a no-break
// space: "1 284".
func FormatCount(n int64) string {
digits := strconv.FormatInt(n, 10)
var b strings.Builder
for i, r := range digits {
if i > 0 && (len(digits)-i)%3 == 0 && digits[i-1] != '-' {
b.WriteString("\u00a0")
}
b.WriteRune(r)
}
return b.String()
}
// FormatMessages writes a message count as the Overview table shows it:
// "1 284 msg".
func FormatMessages(n int64) string { return FormatCount(n) + "\u00a0msg" }
+436
View File
@@ -0,0 +1,436 @@
package view
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
// renderSignedIn renders a page of the kit through the engine, as a handler
// would, with the inbound relay and DMARC switched on.
func renderSignedIn(t *testing.T, page string, data any) string {
t.Helper()
engine := newGuardEngine(t)
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, page, data)
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", page, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func renderFragment(t *testing.T, name string, data any) string {
t.Helper()
engine := newGuardEngine(t)
rec := httptest.NewRecorder()
engine.RenderFragment(rec, http.StatusOK, name, data)
if rec.Code != http.StatusOK {
t.Fatalf("render fragment %s: status %d: %s", name, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func pageHas(t *testing.T, page, out string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(out, want) {
t.Errorf("%s is missing %q", page, want)
}
}
}
func pageLacks(t *testing.T, page, out string, gones ...string) {
t.Helper()
for _, gone := range gones {
if strings.Contains(out, gone) {
t.Errorf("%s carries %q", page, gone)
}
}
}
// ---- Overview
// The stamp, the sentence and the cards say the same thing: the worst card
// colours the stamp, the number counts the cards that are not fine, and the
// lead names them with their detail and a way to the table behind each.
func TestOverviewHeadFollowsTheCards(t *testing.T) {
out := renderSignedIn(t, "overview", overviewFixture())
pageHas(t, "Overview", out,
`<h1 class="title is-3">Running, with warnings.</h1>`,
`class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`,
`Needs attention: <a href="/server/health#certificate">TLS certificate</a> (Expires in 12 days).`,
`Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`,
`class="sp-warn"`, // the certificate card
`href="/server/health"`, `>All details</a>`,
)
cards := overviewFixture().Cards
for i := range cards {
cards[i].Level = ""
}
ok := NewOverview(admin(), "mail.example.org", cards, time.Now(), 30, true)
if p := ok.Head.Postmark; p.Level != LevelOK || p.Word != "OK" || p.Bottom != "6 OF 6 CHECKS" {
t.Errorf("a healthy panel is stamped %+v", *p)
}
if ok.Head.Lead != nil {
t.Errorf("a healthy panel names something that needs attention: %v", ok.Head.Lead)
}
cards[1].Level, cards[2].Level = LevelWarn, LevelFail
bad := NewOverview(admin(), "", cards, time.Now(), 30, true)
if p := bad.Head.Postmark; p.Level != LevelFail || p.Word != "FAIL" || p.Bottom != "2 OF 6 CHECKS" {
t.Errorf("a failing card does not make the stamp FAIL: %+v", *p)
}
if bad.Head.Kicker != "" {
t.Errorf("kicker %q without a host name", bad.Head.Kicker)
}
}
func TestOverviewListsBothKindsOfDomain(t *testing.T) {
out := renderSignedIn(t, "overview", overviewFixture())
pageHas(t, "Overview", out,
`<h2>Outbound domains</h2>`, `<h2>Inbound domains</h2>`,
`<th>30 days</th>`, "1 284 msg", "18 920 msg",
`<a href="/outbound/domains/1"><strong>example.org</strong></a>`,
`<span class="tag is-danger is-light">SPF</span>`, `<span class="tag is-warning is-light">DMARC</span>`,
`<a href="/inbound/domains/2"><strong>acme.io</strong></a>`, `10.0.4.12:2525`,
`href="/outbound/domains">Manage`, `href="/inbound/domains">Manage`,
)
}
// With the inbound relay off the page neither draws nor links the inbound box.
func TestOverviewWithoutInboundHasNoInboundBox(t *testing.T) {
o := NewOverview(admin(), "mail.example.org", overviewFixture().Cards, time.Now(), 30, false)
out := renderSignedIn(t, "overview", o)
pageHas(t, "Overview", out, `<h2>Outbound domains</h2>`)
pageLacks(t, "Overview", out, `Inbound domains`, `<th>MX</th>`)
if got := strings.Count(out, `>Manage</a>`); got != 1 {
t.Errorf("%d Manage links, want the outbound one only", got)
}
if strings.Contains(out, `class="column is-7"`) {
t.Error("the outbound box is squeezed into a column with nothing beside it")
}
}
func TestOverviewEmptyTablesSayWhatIsAbsent(t *testing.T) {
o := NewOverview(admin(), "mail.example.org", overviewFixture().Cards, time.Now(), 30, true)
out := renderSignedIn(t, "overview", o)
pageHas(t, "Overview", out, `No outbound domains yet.`, `No inbound domains yet.`)
pageLacks(t, "Overview", out, `<table`)
}
// A domain name is typed by a person and an upstream is typed by a person.
func TestOverviewEscapesWhatPeopleTyped(t *testing.T) {
o := overviewFixture()
o.OutboundRows[0].Name = `<script>alert(1)</script>`
o.InboundRows[0].Upstream = `"><img src=x>`
out := renderSignedIn(t, "overview", o)
pageLacks(t, "Overview", out, `<script>alert(1)`, `<img src=x>`)
}
func TestFormatMessagesGroupsThousands(t *testing.T) {
for n, want := range map[int64]string{0: "0 msg", 402: "402 msg", 1284: "1 284 msg", 1234567: "1 234 567 msg"} {
if got := FormatMessages(n); got != want {
t.Errorf("FormatMessages(%d) = %q, want %q", n, got, want)
}
}
}
// ---- Health
func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
out := renderSignedIn(t, "health", healthFixture(false))
pageHas(t, "Health", out,
`<h1 class="title is-3">Health</h1>`,
// the two actions: Re-check DNS in the head, Reload configuration under the tables
`action="/server/health/recheck"`, `action="/server/health/reload"`,
// the anchors the Overview cards lead to
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`,
// the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style)
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
`pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`,
`2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
`inet:127.0.0.1:8891`, `→ 203.0.113.25<br>← mail.example.org`,
`hx-get="/server/health/fragment"`,
)
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
if got := strings.Count(out, `class="columns"`); got != 2 {
t.Errorf("Health has %d rows of boxes, want 2", got)
}
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
strings.Contains(out[strings.Index(out, `id="health-machine"`):strings.Index(out, `id="configuration"`)], `action="/server/health/reload"`) {
t.Error("the reload control is inside the polled rows")
}
}
// The fragment is the polled part of the page and nothing else: the same rows,
// the second one marked to be swapped in by its id, no shell and no actions.
func TestHealthFragmentIsThePolledRows(t *testing.T) {
page := renderSignedIn(t, "health", healthFixture(false))
frag := renderFragment(t, "health_body", healthFixture(true))
rows := func(s string) string {
a := strings.Index(s, `<div class="columns" id="health-machine"`)
b := strings.Index(s, `id="configuration"`)
if b < 0 {
b = len(s)
}
return s[a:b]
}
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
want := strings.TrimSpace(rows(page))
if !strings.HasPrefix(want, got) {
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
}
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`)
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
}
// A machine whose counters could not be read keeps its rows: the figure becomes
// a dash and the reason stays in the detail column.
func TestHealthMachineWithoutReadings(t *testing.T) {
h := healthFixture(false)
h.MachineRows = []MachineRow{
{Resource: "CPU", Detail: []string{"The kernel's processor counters (/proc/stat) could not be read here."}},
{Resource: "Network", Detail: []string{"The kernel's network counters (/proc/net/dev) could not be read here."}},
}
out := renderSignedIn(t, "health", h)
pageHas(t, "Health", out, `<span class="sp-muted">—</span>`, `/proc/stat`, `/proc/net/dev`)
pageLacks(t, "Health", out, `<progress`)
}
func TestHealthGaugeColours(t *testing.T) {
for level, want := range map[string]string{LevelOK: "is-success", LevelWarn: "is-warning", LevelFail: "is-danger", "": "is-success"} {
if got := (Gauge{Level: level}).Bar(); got != want {
t.Errorf("a %q gauge is %s, want %s", level, got, want)
}
}
}
func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
h := healthFixture(false)
h.ProcessError = true
h.ProcessRows = nil
h.CertFacts, h.CertDetail, h.CertProblem = nil, "No certificate path is configured (TLS_CERT_FILE).", true
h.SocketRows = []SocketRow{{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", State: Tag{Status: "error"},
Detail: "OpenDKIM socket missing: mail cannot leave."}}
h.SocketRows = append(h.SocketRows, SocketRow{Name: "rspamd", Note: "inbound spam filter", Path: "inet:antispam:11332", State: Tag{Status: "ok"}})
h.HostFacts = []Fact{{Label: "Hostname", Value: Plain("(SELFPOST_HOSTNAME is not set)"), Mono: true}}
h.Flash = &Flash{Text: Plain("DNS re-checked.")}
out := renderSignedIn(t, "health", h)
pageHas(t, "Health", out,
`Could not ask supervisord for the process list.`,
`No certificate path is configured`,
`<span class="tag is-danger is-light">error</span>`, `OpenDKIM socket missing: mail cannot leave.`,
`SELFPOST_HOSTNAME is not set`, `rspamd <span class="sp-muted">· inbound spam filter</span>`,
`<div class="notification is-success is-light">`, `DNS re-checked.`,
)
// the flash sits between the head and the first row, as the kit puts it
if strings.Index(out, `class="notification`) > strings.Index(out, `id="health-machine"`) ||
strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) {
t.Error("the flash is not between page_head and the first box")
}
}
func TestNewHealthFlash(t *testing.T) {
if h := NewHealth(admin(), ""); h.Flash != nil {
t.Error("a flash without a message")
}
h := NewHealth(admin(), "DNS re-checked.")
if h.Flash == nil || h.Flash.Error {
t.Errorf("flash = %+v", h.Flash)
}
}
// ---- Account
func TestAccountHasThreeFormsEachItsOwnPost(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out,
`<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`,
`action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`,
`action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`,
`action="/account/dmarc"`, `name="dmarc_default"`, `name="dmarc_default_address"`, `data-custom-mode="custom"`, `data-custom-address`,
`<option value="hosted">`, `<option value="account" selected>My account e-mail — mix@example.org</option>`,
`<option value="custom">`, `<option value="none">`,
`2 of 3 now`, `<em>admin&#39;s default</em>`,
)
pageLacks(t, "Account", out, `send_log_retention_days`)
// No form asks for another's fields: three forms, three submit buttons.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 3 {
t.Errorf("Account has %d forms, want 3", got)
}
}
func TestAccountReportAuthorizationRecord(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out,
`<h3>Report authorization</h3>`, `<span class="tag is-success is-light">published</span>`,
`*._report._dmarc.example.org`, `v=DMARC1;`, `<span class="tag is-success is-light">authorized</span>`,
)
pageLacks(t, "Account", out, `In DNS now`)
// Not published: what DNS returned is shown, with the fix.
a := accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "error", "The record says something else.", []string{`"v=DMARC2"`})
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `In DNS now`, `&#34;v=DMARC2&#34;`, `The record says something else.`,
`<span class="tag is-danger is-light">not published</span>`, `not authorized`)
// Not found at all: nothing to compare, so the explanation takes the note's place.
a = accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "warn", "No record was found.", nil)
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `No record was found.`)
pageLacks(t, "Account", out, `In DNS now`)
// An address that needs no authorization draws no second record.
a = accountFixture()
a.Authorization, a.DMARC.End = nil, nil
out = renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `Report authorization`)
}
func TestAccountOffersHostedOnlyWithIngest(t *testing.T) {
a := NewAccount(admin(), "global", false, "")
if out := renderSignedIn(t, "account", a); strings.Contains(out, `value="hosted"`) {
t.Error("hosted reports are offered with ingest off")
} else if !strings.Contains(out, `<option value="account">My account e-mail</option>`) {
t.Error("the account e-mail choice names an e-mail the user has not set")
}
}
func TestAccountResultAndRefusalAreTheFlash(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture().WithResult("Profile saved.", ""))
pageHas(t, "Account", out, `<div class="notification is-success is-light">`, `Profile saved.`)
out = renderSignedIn(t, "account", accountFixture().WithResult("", `Current password is <b>incorrect</b>.`))
pageHas(t, "Account", out, `<div class="notification is-danger is-light">`, `Current password is &lt;b&gt;incorrect&lt;/b&gt;.`)
pageLacks(t, "Account", out, `<b>incorrect</b>`)
}
// What a person typed comes back escaped when a form is shown again.
func TestAccountEscapesTheFormValues(t *testing.T) {
a := accountFixture()
a.Username, a.Email, a.DMARCAddress = `"><script>1</script>`, `a"onfocus="x`, `x"><i>`
out := renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`, `<i>`)
}
// Event notifications are not built (roadmap: panel-notifications), and a page
// carries no box for a feature that does not exist.
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
for name, out := range map[string]string{
"Account": renderSignedIn(t, "account", accountFixture()),
"Settings": renderSignedIn(t, "settings", settingsFixture()),
} {
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
}
}
// ---- Settings
func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
out := renderSignedIn(t, "settings", settingsFixture())
if got := strings.Count(out, `<form class="sp-form"`); got != 1 {
t.Errorf("Settings has %d forms, want 1", got)
}
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
}
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
out := renderSignedIn(t, "settings", s)
pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `between 7 and 365`, `value="3"`)
out = renderSignedIn(t, "settings", settingsFixture().WithResult("Settings saved.", ""))
pageHas(t, "Settings", out, `<div class="notification is-success is-light">`, `Settings saved.`)
}
func TestFormatRate(t *testing.T) {
for _, c := range []struct {
messages, window int
want string
}{
{600, 3600, "600 / h"}, {30, 60, "30 / min"}, {5000, 86400, "5 000 / day"},
{100, 7200, "100 / 2 h"}, {10, 90, "10 / 90 s"},
} {
if got := FormatRate(c.messages, c.window); got != c.want {
t.Errorf("FormatRate(%d, %d) = %q, want %q", c.messages, c.window, got, c.want)
}
}
}
// ---- Overview polling
// ids lists every id="…" of a piece of HTML.
func ids(out string) []string {
var all []string
for _, m := range regexp.MustCompile(`\sid="([^"]+)"`).FindAllStringSubmatch(out, -1) {
all = append(all, m[1])
}
return all
}
func unique(t *testing.T, where string, all []string) {
t.Helper()
seen := map[string]bool{}
for _, id := range all {
if seen[id] {
t.Errorf("%s: id %q appears twice", where, id)
}
seen[id] = true
}
}
// The Server health box is the element that polls, and the page never carries
// the out-of-band mark: that belongs to the fragment.
func TestOverviewPageHasThePollAttributesAndNoOOBMark(t *testing.T) {
out := renderSignedIn(t, "overview", overviewFixture())
pageHas(t, "Overview", out,
`<div class="box" id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">`,
`<div class="sp-head" id="overview-head">`)
pageLacks(t, "Overview", out, `hx-swap-oob`)
unique(t, "Overview", ids(out))
}
// The fragment is the box (the element that asked) and the head, out of band —
// nothing of the document around them — and answering a poll with it leaves the
// page with each id once: the box replaces the box, the head replaces the head.
func TestOverviewFragmentIsTheBoxAndTheHeadOutOfBand(t *testing.T) {
frag := renderFragment(t, "overview_poll", overviewFixture())
pageHas(t, "fragment", frag,
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark sp-warn"`, `Updated 14:02:31 UTC`,
`<div class="box" id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">`,
`Expires in 12 days`)
pageLacks(t, "fragment", frag, `<html`, `<body`, `navbar`, `<table`, `Outbound domains`)
unique(t, "fragment", ids(frag))
if got := strings.Count(frag, `hx-swap-oob`); got != 1 {
t.Errorf("%d out-of-band marks in the fragment, want the head's alone", got)
}
// The box is the same markup the page holds.
page := renderSignedIn(t, "overview", overviewFixture())
boxHTML := strings.TrimSpace(frag[strings.Index(frag, `<div class="box" id="health"`):])
if !strings.Contains(page, boxHTML) {
t.Error("the fragment's box is not the page's box")
}
// What a poll leaves behind: the page with its box and head replaced by the
// fragment's. Each id is still there once, and the oob mark is gone with the swap.
pageIDs, fragIDs := ids(page), ids(frag)
for _, id := range fragIDs {
n := 0
for _, p := range pageIDs {
if p == id {
n++
}
}
if n != 1 {
t.Errorf("the fragment's id %q is in the page %d times, so a swap would not replace exactly one element", id, n)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
{{/* Account (mockup account.html): the signed-in user's own — name and e-mail,
password, and where DMARC reports about their domains go. Each form is its
own POST and none of them asks for the others' fields. Every role has this
page; the instance's settings are on Server > Settings (settings.html). */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-5">
{{template "box_open" .Profile}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/account/profile">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Password}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/account/password">
<div class="field"><label class="label" for="current_password">Current password</label><div class="control"><input class="input" id="current_password" name="current_password" type="password" autocomplete="current-password" required></div></div>
<div class="sp-pair">
<div class="field"><label class="label" for="new_password">New password</label><div class="control"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" required></div></div>
<div class="field"><label class="label" for="new_password_confirm">Repeat</label><div class="control"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
</div>
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
<button type="submit" class="button">Change password</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-7">
{{template "box_open" .DMARC}}
{{template "record_open"}}
<form class="sp-form" method="post" action="/account/dmarc">
<div class="field"><label class="label" for="dmarc_default">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="dmarc_default" name="dmarc_default" data-custom-mode="custom">{{$sel := .DMARCSelected}}{{range .DMARCOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .DMARCHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="dmarc_default_address">Report address</label>
<div class="control"><input class="input" id="dmarc_default_address" name="dmarc_default_address" type="email" value="{{.DMARCAddress}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "record_close"}}
{{if .Authorization}}{{template "dns_record" .Authorization}}
{{end}}{{template "box_close"}}
</div>
</div>
{{end}}
+2 -2
View File
@@ -41,7 +41,7 @@
{{/* page_head opens every page (input: Head): the path or section as the
kicker, the h1, a lead or a route line, the postmark when the page has a
verdict, a note on the right, and the buttons. */}}
{{define "page_head"}}<div class="sp-head">
{{define "page_head"}}<div class="sp-head"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}>
{{if .Postmark}}{{template "postmark" .Postmark}}
{{end}}<div class="sp-grow">
{{if .Crumbs}}<p class="sp-kicker">{{range $i, $c := .Crumbs}}{{if $i}} {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Label}}</a>{{else}}{{$c.Label}}{{end}} /{{end}}</p>
@@ -72,7 +72,7 @@
sp-credential (a secret shown once). A box foot, a table in its
table-container, a run of records, a log or an empty state sit directly
inside; anything else goes in body_open … body_close. */}}
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}>
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
+17
View File
@@ -0,0 +1,17 @@
{{/* Server > Health (mockup health.html): every check behind the cards of
Overview, and the one action that is not about DNS, Reload configuration. The
two rows of tables are the polled part (health_body.html); the head and the
Configuration box are static. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{template "health_body" .}}
{{template "box_open" .Configuration}}
{{template "body_open"}}
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{template "action" .Reload}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
@@ -0,0 +1,61 @@
{{/* The polled part of Health: two rows of boxes, in the page and in its
fragment (GET /server/health/fragment) alike. panel.js polls the first row
(data-poll, outerHTML) and htmx swaps the second into place by its id when
the fragment marks it out of band (.Refresh) — the two rows are siblings, and
a fragment can replace only the element that asked for it. */}}
{{define "health_body"}}<div class="columns" id="health-machine" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="column is-6">
{{template "box_open" .Machine}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>{{range .MachineRows}}
<tr><td>{{.Resource}}</td><td>{{if .Gauge}}<progress class="progress is-small {{.Gauge.Bar}} mb-1" value="{{.Gauge.Percent}}" max="100"></progress>{{.Gauge.Text}}{{else if .Usage}}<span class="sp-nowrap">{{.Usage}}</span>{{else}}<span class="sp-muted">—</span>{{end}}</td><td class="sp-muted">{{range .Detail}}<div>{{.}}</div>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Processes}}
{{if .ProcessError}}
{{template "body_open"}}
<p class="has-text-danger">Could not ask supervisord for the process list.</p>
{{template "body_close"}}
{{else}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>{{range .ProcessRows}}
<tr><td>{{.Name}}</td><td>{{template "tag" .State}}</td><td class="sp-muted">{{.Detail}}</td></tr>{{end}}
</tbody>
</table></div>
{{end}}
{{template "box_close"}}
</div>
</div>
<div class="columns" id="health-checks"{{if .Refresh}} hx-swap-oob="true"{{end}}>
<div class="column is-4">
{{template "box_open" .Certificate}}
{{template "body_open"}}
{{if .CertFacts}}{{template "facts" .CertFacts}}{{end}}
<p class="mt-4 {{if .CertProblem}}has-text-danger{{else}}sp-muted{{end}}">{{.CertDetail}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Sockets}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>{{range .SocketRows}}
<tr><td>{{.Name}}{{if .Note}} <span class="sp-muted">· {{.Note}}</span>{{end}}<br><span class="sp-muted sp-mono sp-small">{{.Path}}</span>{{if .Detail}}<br><span class="sp-muted sp-small">{{.Detail}}</span>{{end}}</td><td>{{template "tag" .State}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Hostname}}
{{template "body_open"}}
{{template "facts" .HostFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{/* Overview (mockup overview.html): the verdict in the head, the six checks as
cards, and the two kinds of domain at a glance. Everything behind a card is
on Health. The inbound box exists only while the inbound relay is on; without
it the outbound box takes the whole width. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "overview_cards" .}}
{{if .InboundOn}}
<div class="columns">
<div class="column is-7">
{{template "overview_outbound" .}}
</div>
<div class="column is-5">
{{template "overview_inbound" .}}
</div>
</div>
{{else}}
{{template "overview_outbound" .}}
{{end}}
{{end}}
{{define "overview_outbound"}}
{{template "box_open" .Outbound}}
{{if .OutboundRows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>{{.Days}} days</th></tr></thead>
<tbody>{{range .OutboundRows}}
<tr><td><a href="{{.Href}}"><strong>{{.Name}}</strong></a></td><td>{{range $i, $t := .DNS}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td><td>{{.Apps}}</td><td class="sp-muted">{{.Messages}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .OutboundEmpty}}
{{end}}
{{template "box_close"}}
{{end}}
{{define "overview_inbound"}}
{{template "box_open" .Inbound}}
{{if .InboundRows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
<tbody>{{range .InboundRows}}
<tr><td><a href="{{.Href}}"><strong>{{.Name}}</strong></a></td><td>{{template "tag" .MX}}</td><td class="sp-muted sp-mono sp-small">{{.Upstream}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .InboundEmpty}}
{{end}}
{{template "box_close"}}
{{end}}
@@ -0,0 +1,12 @@
{{/* The polled part of Overview, in the page and in its fragment (GET
/overview/fragment) alike, so the two cannot drift apart: the Server health
box, which polls (Box.Poll: panel.js reads its id and data-poll), and — in the
fragment only — the page head marked to be swapped in by its id, so the stamp,
the sentence and the time follow the cards. The fragment's primary element is
the box, which replaces the one that asked; the head rides along out of band. */}}
{{define "overview_cards"}}{{template "box_open" .Health}}
{{template "health_cards" .Cards}}
{{template "box_close"}}{{end}}
{{define "overview_poll"}}{{template "page_head" .HeadOOB}}
{{template "overview_cards" .}}{{end}}
+23 -145
View File
@@ -1,152 +1,30 @@
{{/* One legacy template for two pages until stage 2 gives each its own:
Account (GET /account, every role) — the user's name and e-mail, their
password and their default DMARC report address, each its own form — and
Server › Settings (GET /server/settings, global role) — what holds for the
whole instance. .AccountPage says which one this is. */}}
{{define "wide"}}wide{{end}}
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
One form, one Save button; the rate limits are read-only facts. A user's own
name, e-mail, password and DMARC address are on Account (account.html). */}}
{{define "content"}}
{{if .AccountPage}}
<h1>Account</h1>
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="split">
<div class="card" id="profile">
<h2>Profile</h2>
<p class="muted">Signed in as {{.User}} · {{.Role}}. These are the
credentials for this control panel only. Applications keep their own logins
and passwords, which are not affected.</p>
<form method="post" action="/account/profile">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" autocomplete="email"
autocapitalize="none" spellcheck="false" value="{{.FormEmail}}">
<p class="muted">Where the panel writes to you. It can also receive DMARC
reports — your choice, under DMARC reports.</p>
<button type="submit">Save profile</button>
</form>
</div>
<div class="card" id="password">
<h2>Password</h2>
<form method="post" action="/account/password">
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" required>
<label for="new_password_confirm">Repeat</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required>
<button type="submit">Change password</button>
</form>
<p class="muted">Changing it signs out every other session of this account;
this one stays signed in.</p>
</div>
</div>
<div class="card" id="dmarc">
<h2>DMARC reports</h2>
<p class="muted">Your default report address. It is used by every outbound
domain whose report address is set to follow your default; changing it
changes the DMARC record those domains must publish.</p>
<form method="post" action="/account/dmarc">
<label for="dmarc_default">My default report address</label>
<select id="dmarc_default" name="dmarc_default" data-custom-mode="custom">
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .FormDMARCMode "hosted"}} selected{{end}}>SelfPost hosted — read them under DMARC</option>{{end}}
<option value="account"{{if eq .FormDMARCMode "account"}} selected{{end}}>My account e-mail{{if .AccountEmail}} — {{.AccountEmail}}{{end}}</option>
<option value="custom"{{if eq .FormDMARCMode "custom"}} selected{{end}}>Another address…</option>
<option value="none"{{if eq .FormDMARCMode "none"}} selected{{end}}>No reports</option>
</select>
<div data-custom-address>
<label for="dmarc_default_address">Report address</label>
<input id="dmarc_default_address" name="dmarc_default_address" type="email"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormDMARCAddress}}" placeholder="reports@your-mail-domain.com">
<form class="sp-form" method="post" action="/server/settings">
<div class="columns">
<div class="column is-6">
{{template "box_open" .Retention}}
{{template "body_open"}}
<div class="field"><label class="label" for="send_log_retention_days">Keep outbound log rows, days</label>
<div class="control"><input class="input" id="send_log_retention_days" name="send_log_retention_days" type="number" min="7" max="365" step="1" value="{{.RetentionDays}}" required></div>
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<button type="submit">Save report address</button>
</form>
{{if .ReportAuthName}}
<p class="muted">When <code>rua=</code> points at another domain, that hub
domain must publish a report-authorisation record so receivers will deliver
the XML aggregates.</p>
<label>Report authorization — host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
<div class="column is-6">
{{template "box_open" .RateLimits}}
{{template "body_open"}}
{{template "facts" .RateFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
<label>Report authorization — value</label>
<div class="code-row">
<span class="code">{{.ReportAuthExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .ReportAuthDNS.Status}}
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
{{end}}
</div>
{{else}}
<h1>Settings</h1>
<p class="muted">What holds for the whole instance, whoever is signed in. Your
own name, e-mail, password and DMARC report address are under
<a href="/account">Account</a>.</p>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/server/settings">
<div class="card" id="deliveries-retention">
<h2>Deliveries</h2>
<p class="muted">How long rows on <a href="/outbound/log">Deliveries</a> are
kept before the background sweep deletes them — the main driver of
<code>/data</code> growth over time. Does not affect <code>mail.log</code>
rotation (fourteen daily files).</p>
<label for="send_log_retention_days">Send log retention (days)</label>
<input id="send_log_retention_days" name="send_log_retention_days" type="number"
min="7" max="365" step="1" required
value="{{.FormSendLogRetentionDays}}">
<button type="submit">Save settings</button>
</div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Configured in <code>.env</code> / Compose; restart the
container to change level&nbsp;1. Domain and application ceilings are set on
each domain's page.</p>
<label>Level 1 — per client IP (Postfix)</label>
<p class="code-row"><span class="code">{{.L1Messages}} messages / {{.L1Window}} seconds</span></p>
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> /
<code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP;
the panel cannot raise a domain or application limit above this.</p>
<label>Level 2 — domain</label>
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When
unset, only level&nbsp;1 applies. Must be ≤ level&nbsp;1.</p>
<label>Level 2 — application</label>
<p class="muted">Overrides the domain limit for one application. The ceiling
may be <em>above or below</em> the domain setting (≤ level&nbsp;1). When unset,
the domain limit or level&nbsp;1 applies. Independent of the client IP
allow-list.</p>
</div>
{{end}}
{{end}}
-31
View File
@@ -1,31 +0,0 @@
{{/* Wide so Machine|Processes and the other .split pairs fill the column
rather than the 48rem reading measure. */}}
{{define "wide"}}wide{{end}}
{{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">
<h1>{{if .Overview}}Overview{{else}}Health{{end}}</h1>
<label class="help-link" for="help-status" title="What these checks mean">?</label>
</div>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{template "status_body" .}}
{{if not .Overview}}
<div class="card" id="configuration">
<h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
database and reloads both daemons. Use it if you edited the files by hand,
restored a backup, or the running configuration looks out of step with the
domain and application lists. It does not touch the mail queue or the TLS
certificate, and it is safe to run at any time.</p>
<form class="inline" method="post" action="/server/health/reload">
<button type="submit">Reload configuration</button>
</form>
</div>
{{end}}
{{end}}
@@ -1,154 +0,0 @@
{{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 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">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
</div>
<div class="split">
<div class="card" id="machine">
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
<table>
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr>
<td class="metric">CPU</td>
<td class="metric">
{{if .Machine.CPU.Measured}}
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
{{.Machine.CPU.BusyText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">{{.Machine.CPU.Detail}}</td>
</tr>
<tr>
<td class="metric">Memory</td>
<td class="metric">
{{if .Machine.Memory.Measured}}
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
{{.Machine.Memory.PctText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">{{.Machine.Memory.Detail}}</td>
</tr>
<tr>
<td class="metric">Network</td>
<td class="metric">
{{if .Machine.Network.Measured}}
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">
{{range .Machine.Network.Interfaces}}
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out</div>
{{end}}
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card" id="processes">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
{{if .ProcessError}}
<p class="error">Could not ask supervisord for the process list.</p>
{{else}}
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
{{range .Processes}}
<tr>
<td>{{.Name}}</td>
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
<td class="muted">{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
<div class="split">
<div class="card" id="queue">
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
{{if .QueueError}}
<p class="error">{{.QueueError}}</p>
{{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
<a class="btn" href="/outbound/queue">View queue</a>
{{end}}
</div>
<div class="card" id="certificate">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
{{if not .Cert.NotAfter.IsZero}}
<label>Expires</label>
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
{{end}}
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
</div>
</div>
<div class="split">
<div class="card" id="sockets">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
{{range .Sockets}}
<tr>
<td>{{.Name}}</td>
<td><span class="st st-{{.Status}}">{{.Status}}</span></td>
<td class="muted">{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="card" id="hostname">
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
<label>Server hostname</label>
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
{{if .PTR.Records}}
<label>Forward and reverse lookup</label>
<span class="code">{{range .PTR.Records}}{{.}}
{{end}}</span>
{{end}}
<form class="inline" method="post" action="/server/health/recheck">
<button type="submit">Re-check DNS</button>
</form>
</div>
</div>
</div>
{{end}}
{{end}}
+42 -176
View File
@@ -9,9 +9,6 @@ import (
"regexp"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/health"
)
// The navigation is rendered from the layout, not copied into each page, so
@@ -169,7 +166,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
@@ -198,7 +195,7 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
@@ -228,8 +225,8 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
"status": true, "system_log": true, "domain_detail": true,
"deliveries": true, "delivery": true, "mail_queue": true,
"system_log": true, "domain_detail": true,
"inbound": true, "inbound_domain": true, "dmarc": true,
}
for name, page := range engine.Pages() {
@@ -303,26 +300,23 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
}
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/settings.html")
if err != nil {
t.Fatalf("read settings: %v", err)
}
src := string(body)
if !strings.Contains(src, `id="rate-limits"`) {
t.Error("settings should include a sending rate limits card")
}
if !strings.Contains(src, `id="deliveries-retention"`) {
t.Error("settings should include a send log retention card for global administrators")
}
out := renderSignedIn(t, "settings", settingsFixture())
for _, want := range []string{
"RATE_LIMIT_MESSAGES_PER_IP",
"Level 2 — domain",
"Level 2 — application",
"{{.L1Messages}} messages / {{.L1Window}} seconds",
`name="send_log_retention_days"`,
`id="rate-limits"`,
"Sending rate limits",
"Level 1 · per client IP", "600 / h", "<code>.env</code>",
"Level 2 · domain, application", `href="/outbound/domains"`,
`name="send_log_retention_days"`, `value="30"`, "Keep outbound log rows, days",
`action="/server/settings"`,
} {
if !strings.Contains(src, want) {
t.Errorf("settings rate limits card missing %q", want)
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q", want)
}
}
// What belongs to a user is on Account.
for _, gone := range []string{`name="current_password"`, `name="username"`, `name="dmarc_default"`, `name="email"`} {
if strings.Contains(out, gone) {
t.Errorf("the instance settings page carries the user's %s", gone)
}
}
}
@@ -365,14 +359,23 @@ func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
})
}
// The reload action is a server-health control and lives only on the status
// page.
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
// The reload action is a server-health control and lives only on the Health
// page (as a partial call: its path is in the page's data, so no template but
// the view code names it).
func TestReloadFormLivesOnlyOnTheHealthPage(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `action="/server/health/reload"`) && name != "status.html" {
t.Errorf("%s still posts to /server/health/reload; the reload control belongs on the status page", name)
if strings.Contains(body, "/server/health/reload") {
t.Errorf("%s names /server/health/reload; the reload control belongs to NewHealth", name)
}
})
if got := renderSignedIn(t, "health", healthFixture(false)); !strings.Contains(got, `action="/server/health/reload"`) {
t.Error("Health has no Reload configuration form")
}
for _, page := range []string{"overview", "account", "settings"} {
if strings.Contains(renderSignedIn(t, page, pageFixtures[page]()), "/server/health/reload") {
t.Errorf("%s offers the reload control", page)
}
}
}
// The panel's Content-Security-Policy is a plain default-src 'self' with no
@@ -424,148 +427,6 @@ func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
}
}
func TestStatusPageRendersEveryCheck(t *testing.T) {
out := renderStatusPage(t, statusPageData())
for _, want := range []string{
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
"203.0.113.10 → no PTR record", `action="/server/health/reload"`,
`hx-get="/server/health/fragment"`, `class="st st-error"`,
// Three .split rows inside the polled fragment: machine|processes,
// queue|certificate, and sockets|hostname. Ids stay on the cards.
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
`action="/server/health/recheck"`,
// 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 meter.
`<meter value="12"`, `<meter value="50"`,
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
"eth0: 1.0 MiB in, 512.0 KiB out",
} {
if !strings.Contains(out, want) {
t.Errorf("status page is missing %q", want)
}
}
if got := strings.Count(out, `class="split"`); got != 3 {
t.Errorf("status page has %d .split rows, want 3", got)
}
// Hostname must live inside the fragment so a poll refresh keeps it beside
// sockets; Configuration stays outside (static reload control).
body := strings.Index(out, `id="status-body"`)
conf := strings.Index(out, `id="configuration"`)
if body < 0 || conf < 0 || conf < body {
t.Fatal("status-body or configuration card missing or out of order")
}
frag := out[body:conf]
if !strings.Contains(frag, `id="hostname"`) {
t.Error("hostname card is outside the polled status-body fragment")
}
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/server/health/reload"`) {
t.Error("configuration reload must stay outside the polled fragment")
}
}
// A machine whose counters could not be read — no /proc, or a first reading
// with nothing to compare against — must leave the card in place with its rows
// blank, the same way an unreachable supervisord costs one line and not the
// page.
func TestStatusPageWithoutMachineMetrics(t *testing.T) {
data := statusPageData()
data["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 := renderStatusPage(t, data)
if strings.Contains(out, "<meter") {
t.Error("a bar was drawn for a reading that does not exist")
}
for _, want := range []string{
`<h2>Machine <span class="st st-unknown">`,
"/proc/stat", "/proc/meminfo", "/proc/net/dev",
} {
if !strings.Contains(out, want) {
t.Errorf("degraded machine card is missing %q", want)
}
}
}
func renderStatusPage(t *testing.T, data map[string]any) string {
t.Helper()
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "layout_legacy.html", data); err != nil {
t.Fatalf("execute status page: %v", err)
}
return buf.String()
}
// statusPageData is one plausible reading of every check the status page shows,
// so a test can render the page and vary the one part it is about.
func statusPageData() map[string]any {
return map[string]any{
"Title": "SelfPost — status",
"User": "admin",
"Active": "status",
"Processes": []health.Process{
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
},
"ProcessStatus": health.StatusError,
"QueueSummary": "Mail queue is empty",
"QueueStatus": health.StatusOK,
"Cert": health.Certificate{
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30,
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
},
"Machine": health.Machine{
CPU: health.CPU{
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
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,
Interfaces: []health.Interface{
{Name: "eth0", RxBytes: 1 << 20, TxBytes: 1 << 19, RxRate: 2048, TxRate: 1024, Measured: true},
},
Status: health.StatusOK,
},
Window: 5 * time.Second,
Status: health.StatusOK,
},
"Sockets": []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
},
"SocketStatus": health.StatusOK,
"OverallStatus": health.StatusError,
"OverallHeading": "A component needs attention — see the details below.",
"Hostname": "mail.example.com",
"PTR": dnscheckResult{
Status: health.StatusError,
Detail: "No address has a reverse record.",
Records: []string{"203.0.113.10 → no PTR record"},
},
}
}
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the
// view package's template tests do not depend on the checker's constructor.
type dnscheckResult struct {
Status health.Status
Detail string
Records []string
}
// forEachTemplate runs fn over every embedded template's source.
func forEachTemplate(t *testing.T, fn func(name, body string)) {
t.Helper()
@@ -657,10 +518,15 @@ func TestLoginPageOmitsHelpDrawer(t *testing.T) {
}
}
func TestStatusPageHasHelpEntry(t *testing.T) {
out := renderStatusPage(t, statusPageData())
if !strings.Contains(out, `for="help-status"`) {
t.Error("status page is missing the help entry point")
// The help drawer is gone from the pages of the kit: Overview points at the
// section of the Help page that explains its checks, from the head of its box.
func TestOverviewLinksToItsHelpTopic(t *testing.T) {
out := renderSignedIn(t, "overview", overviewFixture())
if !strings.Contains(out, `class="sp-help" href="/help#checks"`) {
t.Error("the Server health box has no link to its Help topic")
}
if strings.Contains(out, "help-drawer") {
t.Error("a page of the kit carries the old help drawer")
}
}
+20 -3
View File
@@ -9,6 +9,7 @@ import (
"html/template"
"log"
"net/http"
"path"
"github.com/mixeme/selfpost/internal/legal"
)
@@ -38,6 +39,9 @@ var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"overview": {"templates/overview.html", "templates/overview_cards.html"},
"health": {"templates/health.html", "templates/health_body.html"},
"account": {"templates/account.html"},
"settings": {"templates/settings.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
@@ -55,7 +59,6 @@ var pageFiles = map[string][]string{
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"},
"help": {"templates/help.html"},
"components": {"templates/kit.html"},
}
@@ -70,6 +73,10 @@ var kitPages = map[string]bool{
"components": true,
"login": true,
"setup": true,
"overview": true,
"health": true,
"account": true,
"settings": true,
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
@@ -78,9 +85,13 @@ var fragmentFiles = map[string]string{
"deliveries_rows": "templates/deliveries_rows.html",
"mail_queue_body": "templates/mail_queue_body.html",
"system_log_body": "templates/system_log_body.html",
"status_body": "templates/status_body.html",
"health_body": "templates/health_body.html",
"overview_poll": "templates/overview_cards.html",
}
// kitFragments are the fragments rendered with the component kit's partials.
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true}
// New parses embedded templates. version is stamped into every page footer.
func New(version string) (*Engine, error) {
e := &Engine{
@@ -103,7 +114,13 @@ func New(version string) (*Engine, error) {
e.pages[name] = tmpl
}
for name, file := range fragmentFiles {
tmpl, err := template.ParseFS(assetsFS, file)
// A fragment of a kit page calls the kit's partials, so it parses with
// them; the old fragments define everything they use themselves.
patterns := []string{file}
if kitFragments[name] {
patterns = []string{"templates/components.html", file}
}
tmpl, err := template.New(path.Base(file)).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
if err != nil {
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
}