diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d613c..8ec85f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/assets/panel-redesign/evidence/account.png b/docs/assets/panel-redesign/evidence/account.png new file mode 100644 index 0000000..a98f1f3 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/account.png differ diff --git a/docs/assets/panel-redesign/evidence/health.png b/docs/assets/panel-redesign/evidence/health.png new file mode 100644 index 0000000..b7b27b0 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/health.png differ diff --git a/docs/assets/panel-redesign/evidence/overview.png b/docs/assets/panel-redesign/evidence/overview.png new file mode 100644 index 0000000..f2cb1a0 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/overview.png differ diff --git a/docs/assets/panel-redesign/evidence/settings.png b/docs/assets/panel-redesign/evidence/settings.png new file mode 100644 index 0000000..15583df Binary files /dev/null and b/docs/assets/panel-redesign/evidence/settings.png differ diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 2735bcc..34fd108 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -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** diff --git a/docs/roadmap.md b/docs/roadmap.md index b776bc7..b4b7086 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/internal/web/handlers/authz.go b/internal/web/handlers/authz.go index 4a3bfa6..a2d088e 100644 --- a/internal/web/handlers/authz.go +++ b/internal/web/handlers/authz.go @@ -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() diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index 290929d..82f71cd 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -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 { diff --git a/internal/web/handlers/handlers_account_test.go b/internal/web/handlers/handlers_account_test.go index 2cd4a6d..1f2ba28 100644 --- a/internal/web/handlers/handlers_account_test.go +++ b/internal/web/handlers/handlers_account_test.go @@ -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`, `

Account

`, + `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">x`}, "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, `x`) { + 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) + } +} diff --git a/internal/web/handlers/handlers_health_page_test.go b/internal/web/handlers/handlers_health_page_test.go new file mode 100644 index 0000000..f8f993f --- /dev/null +++ b/internal/web/handlers/handlers_health_page_test.go @@ -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"`, + `error`, // 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. + `unknown`} { + 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)", + `
`, "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{"`, `class="sp-postmark`, `

mail.example.org

`, + `href="/server/health#certificate"`, `href="/server/health#sockets"`, `href="/outbound/queue"`, + `

Outbound domains

`, `example.org`, + "DKIM", "SPF", "DMARC", "1\u00a0msg", `30 days`, + `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"`, "`); got != 6 { + t.Errorf("the fragment holds %d cards, want 6", got) + } + for _, want := range []string{ + `
`, `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{" 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") != "": diff --git a/internal/web/handlers/handlers_status_test.go b/internal/web/handlers/handlers_status_test.go index db83eec..e5a2366 100644 --- a/internal/web/handlers/handlers_status_test.go +++ b/internal/web/handlers/handlers_status_test.go @@ -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{ - "

Overview

", `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"`, "Health", `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") - } } diff --git a/internal/web/view/account.go b/internal/web/view/account.go new file mode 100644 index 0000000..d69f108 --- /dev/null +++ b/internal/web/view/account.go @@ -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" +} diff --git a/internal/web/view/components.go b/internal/web/view/components.go index d45ec11..eead114 100644 --- a/internal/web/view/components.go +++ b/internal/web/view/components.go @@ -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. diff --git a/internal/web/view/components_test.go b/internal/web/view/components_test.go index 934f171..ae879bc 100644 --- a/internal/web/view/components_test.go +++ b/internal/web/view/components_test.go @@ -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, `
`+"\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, `
`+"\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, `
`+"\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, `
`) + got = partial(t, "page_head", Head{Title: "T", ID: "h", OOB: true}) + mustContain(t, got, `
`) +} diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index a9c6151..1cf1f51 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -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)) } diff --git a/internal/web/view/health.go b/internal/web/view/health.go new file mode 100644 index 0000000..37c7a2a --- /dev/null +++ b/internal/web/view/health.go @@ -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, "")} } diff --git a/internal/web/view/legacy_pages.txt b/internal/web/view/legacy_pages.txt index 88cf13c..05a73c0 100644 --- a/internal/web/view/legacy_pages.txt +++ b/internal/web/view/legacy_pages.txt @@ -24,8 +24,6 @@ inbound inbound_delete inbound_domain mail_queue -settings -status system_log user_delete user_form diff --git a/internal/web/view/overview.go b/internal/web/view/overview.go new file mode 100644 index 0000000..c90d5ee --- /dev/null +++ b/internal/web/view/overview.go @@ -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" } diff --git a/internal/web/view/pages_test.go b/internal/web/view/pages_test.go new file mode 100644 index 0000000..9219d18 --- /dev/null +++ b/internal/web/view/pages_test.go @@ -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, + `

Running, with warnings.

`, + `class="sp-postmark sp-warn"`, `WARN`, `1 OF 6 CHECKS`, + `Needs attention: TLS certificate (Expires in 12 days).`, + `Updated 14:02:31 UTC`, `

mail.example.org

`, + `class="sp-warn"`, // the certificate card + `href="/server/health"`, `>All details`, + ) + + 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, + `

Outbound domains

`, `

Inbound domains

`, + `30 days`, "1 284 msg", "18 920 msg", + `example.org`, + `SPF`, `DMARC`, + `acme.io`, `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, `

Outbound domains

`) + pageLacks(t, "Overview", out, `Inbound domains`, `MX`) + if got := strings.Count(out, `>Manage`); 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, `alert(1)` + o.InboundRows[0].Upstream = `">` + out := renderSignedIn(t, "overview", o) + pageLacks(t, "Overview", out, ``, `a"onfocus="x`, `x">` + out := renderSignedIn(t, "account", a) + pageLacks(t, "Account", out, `