panel: denser Status layout with true two-column cards
test / test (push) Has been cancelled

Pair Machine|Processes, queue|cert, and sockets|hostname; fix .split
auto-margins so cards fill half the row; trim Status prose and machine
details; drop the page section index; note panel-docs on the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 00:29:34 +03:00
parent 6b8658602d
commit 93cf1de3b7
11 changed files with 337 additions and 199 deletions
+17 -4
View File
@@ -7,13 +7,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- panel: **Status** places Mail queue and TLS certificate side by side (the - panel: **Status** is wide again so paired cards fill the column. Layout:
same `.split` / wide layout as Settings and a delivery), so the shorter Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖
checks take less vertical space; Processes, Machine, sockets, hostname, and Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS
Configuration stay full width. certificate, and Hostname (and Hostname's trailing detail line); milter
socket paths omitted from the table; queue link reads **View queue**; milter
ok detail is `Listening` without a trailing period and sits in its own
Detail column beside the status badge; CPU detail is only core and thread
counts (no load average); memory detail is `N used of M` without the
«available to new work» clause; network detail lists per-interface totals
only (rates stay in the Usage column). No «On this page» section index —
the paired layout is short enough. Hostname stays in the polled fragment so
the pair survives HTMX refresh. In-panel docs for the removed blurbs filed
as roadmap `panel-docs`.
### Fixed ### Fixed
- panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
shrinks each card to its content and centres it in the track instead of
filling half the row. Side margins are cancelled for `.split > .card`
(Status, Settings, and a delivery's message/history).
- panel: on the user create/edit form, **Assigned domains** checkboxes stacked - panel: on the user create/edit form, **Assigned domains** checkboxes stacked
the box above the domain name (and stretched it full-width) because the form's the box above the domain name (and stretched it full-width) because the form's
block-label and full-width input rules applied to them. Checkbox rows now use block-label and full-width input rules applied to them. Checkbox rows now use
+1 -1
View File
@@ -189,7 +189,7 @@ service healthy and will mail be accepted?"
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel), - **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
TLS certificate validity and expiry, milter socket presence, and a short TLS certificate validity and expiry, milter socket presence, and a short
Postfix queue summary. The **Machine** card adds the resource usage of the Postfix queue summary. The **Machine** card adds the resource usage of the
host underneath — processor (with the load average), memory and swap, and host underneath — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals — read from the kernel's per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of one refresh after the page opens. A fully busy processor or a machine out of
+26
View File
@@ -30,6 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| contributing | `CONTRIBUTING.md` | candidate | — | | contributing | `CONTRIBUTING.md` | candidate | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
| logrotate-mode | `mail.log` stops rotating in some builds | candidate | [plans/logrotate-mode.md](plans/logrotate-mode.md) | | logrotate-mode | `mail.log` stops rotating in some builds | candidate | [plans/logrotate-mode.md](plans/logrotate-mode.md) |
| panel-docs | In-panel operator documentation | candidate | — |
**Recommended order** (not binding): **inbound-relay** first among agreed **Recommended order** (not binding): **inbound-relay** first among agreed
items — it is the largest remaining 1.x+ extension. Candidates need explicit items — it is the largest remaining 1.x+ extension. Candidates need explicit
@@ -102,3 +103,28 @@ source of truth for `rua=` in DNS guidance.
inbound-relay depending on how port 25 acceptance is structured. inbound-relay depending on how port 25 acceptance is structured.
**Version:** `1.x` MINOR. **Version:** `1.x` MINOR.
---
## panel-docs
**Goal:** built-in operator documentation in the panel — short pages (or a
help drawer) that explain what each Status check and other controls mean,
without sending the operator out to `docs/guide.md`.
**Boundary:** in-panel help only; not a second copy of the full operator guide.
Seed content includes the Status blurbs removed from the cards in favour of a
denser layout — Machine (kernel counters / rate window), TLS certificate
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
reverse DNS, PTR at the hosting provider), and similar notes for other panel
surfaces as they lose inline commentary.
**Done when:** an operator can open help from the panel for those topics; the
removed Status blurbs are preserved there (or equivalent); no requirement to
read the git tree for day-to-day meaning of a card.
**Dependencies / risks:** copy ownership and translation; keeping help in sync
when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
+101 -36
View File
@@ -62,10 +62,14 @@ type CPU struct {
// nothing until it is true. // nothing until it is true.
Measured bool Measured bool
BusyPct float64 BusyPct float64
Cores int // Cores and Threads are physical cores and logical CPUs (SMT). Both come
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
// the cpuN lines in /proc/stat and Cores matches Threads.
Cores int
Threads int
// Load is the 1/5/15-minute load average, present when /proc/loadavg // Load is the 1/5/15-minute load average, present when /proc/loadavg
// could be read. Unlike BusyPct it needs no previous sample, so it is // could be read. Unlike BusyPct it needs no previous sample. It is kept
// there on the very first page load. // for the sampler but is no longer shown on the Status card.
Load [3]float64 Load [3]float64
HasLoad bool HasLoad bool
Status Status Status Status
@@ -176,7 +180,14 @@ type netCounters struct {
func (m *MachineSampler) Sample() Machine { func (m *MachineSampler) Sample() Machine {
root := m.root() root := m.root()
now := time.Now() now := time.Now()
cpuNow, cores, cpuErr := readCPUTimes(root) cpuNow, logical, cpuErr := readCPUTimes(root)
cores, threads := readCPUTopology(root)
if threads == 0 {
threads = logical
}
if cores == 0 {
cores = threads
}
netNow, netErr := readNetDev(root) netNow, netErr := readNetDev(root)
m.mu.Lock() m.mu.Lock()
@@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine {
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
mach := Machine{ mach := Machine{
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr), CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
Memory: readMemory(root), Memory: readMemory(root),
Network: networkUsage(prevNet, netNow, window, fresh, netErr), Network: networkUsage(prevNet, netNow, window, fresh, netErr),
} }
@@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine {
return mach return mach
} }
// cpuUsage grades the processor over the window. The load average is reported // cpuUsage grades the processor over the window. Detail carries only the
// alongside it because the two answer different questions — how busy the // core and thread counts — load average and busy prose stay out of the Status
// processor was, and how many tasks were waiting for it — and a machine can // card (see roadmap panel-docs for operator-facing explanation later).
// look idle while work queues up behind a slow disk. func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU { c := CPU{Cores: cores, Threads: threads}
c := CPU{Cores: cores}
if !isZeroLoad(load) { if !isZeroLoad(load) {
c.Load, c.HasLoad = load, true c.Load, c.HasLoad = load, true
} }
@@ -223,9 +233,9 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here." c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
return c return c
} }
c.Detail = c.contextText()
if !fresh || cur.total <= prev.total { if !fresh || cur.total <= prev.total {
c.Status = StatusUnknown c.Status = StatusUnknown
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
return c return c
} }
@@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
if c.BusyPct >= cpuWarnPct { if c.BusyPct >= cpuWarnPct {
c.Status = StatusWarn c.Status = StatusWarn
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
} else { } else {
c.Status = StatusOK c.Status = StatusOK
c.Detail = c.contextText()
} }
return c return c
} }
// contextText is the CPU's supporting figures: what the percentage is a // contextText is the CPU detail column: physical cores and logical threads.
// percentage of, and how deep the run queue is.
func (c CPU) contextText() string { func (c CPU) contextText() string {
var parts []string switch {
if c.Cores > 0 { case c.Cores > 0 && c.Threads > 0:
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores)) return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
case c.Threads > 0:
return fmt.Sprintf("%d threads", c.Threads)
case c.Cores > 0:
return fmt.Sprintf("%d cores", c.Cores)
default:
return ""
} }
if c.HasLoad {
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
}
return strings.Join(parts, " · ")
} }
// readMemory reports main memory from /proc/meminfo. Used is derived from // readMemory reports main memory from /proc/meminfo. Used is derived from
@@ -296,8 +305,7 @@ func readMemory(root string) Memory {
m.SwapUsedBytes = m.SwapTotalBytes - swapFree m.SwapUsedBytes = m.SwapTotalBytes - swapFree
} }
detail := fmt.Sprintf("%s used of %s; %s available to new work.", detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
if m.SwapTotalBytes > 0 { if m.SwapTotalBytes > 0 {
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes)) detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
} }
@@ -418,6 +426,74 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
return times, cores, nil return times, cores, nil
} }
// readCPUTopology returns physical core and logical thread counts from
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
// back to the cpuN count from /proc/stat.
func readCPUTopology(root string) (cores, threads int) {
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
if err != nil {
return 0, 0
}
type coreKey struct{ phys, core int }
seen := map[coreKey]bool{}
var (
inCPU bool
phys = -1
core = -1
idx int
)
flush := func() {
if !inCPU {
return
}
p, c := phys, core
if p < 0 {
p = idx
}
if c < 0 {
c = idx
}
seen[coreKey{p, c}] = true
inCPU, phys, core = false, -1, -1
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
flush()
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "processor":
flush()
inCPU = true
threads++
idx = threads - 1
if n, err := strconv.Atoi(val); err == nil {
idx = n
}
case "physical id":
if n, err := strconv.Atoi(val); err == nil {
phys = n
}
case "core id":
if n, err := strconv.Atoi(val); err == nil {
core = n
}
}
}
flush()
if threads == 0 {
return 0, 0
}
return len(seen), threads
}
// readLoadAvg reads the 1/5/15-minute load averages. A machine without // readLoadAvg reads the 1/5/15-minute load averages. A machine without
// /proc/loadavg simply has none reported, so the failure is a zero value rather // /proc/loadavg simply has none reported, so the failure is a zero value rather
// than an error. // than an error.
@@ -544,14 +620,3 @@ func humanRate(perSec float64) string {
} }
return humanBytes(uint64(perSec+0.5)) + "/s" return humanBytes(uint64(perSec+0.5)) + "/s"
} }
// joinDetail joins the non-empty parts of a detail line.
func joinDetail(parts ...string) string {
var kept []string
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " ")
}
+45 -10
View File
@@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
// 1000 ticks pass, 250 of them idle: 75% busy. // 1000 ticks pass, 250 of them idle: 75% busy.
prev := cpuTimes{total: 10000, idle: 8000} prev := cpuTimes{total: 10000, idle: 8000}
cur := cpuTimes{total: 11000, idle: 8250} cur := cpuTimes{total: 11000, idle: 8250}
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil) got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil)
if !got.Measured { if !got.Measured {
t.Fatalf("reading not marked measured: %+v", got) t.Fatalf("reading not marked measured: %+v", got)
} }
@@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
if got.Status != StatusOK { if got.Status != StatusOK {
t.Errorf("status = %q, want ok", got.Status) t.Errorf("status = %q, want ok", got.Status)
} }
if got.Cores != 4 || !got.HasLoad { if got.Cores != 4 || got.Threads != 8 || !got.HasLoad {
t.Errorf("cores/load not reported: %+v", got) t.Errorf("cores/threads/load not reported: %+v", got)
}
if got.Detail != "4 cores · 8 threads" {
t.Errorf("detail = %q, want cores and threads only", got.Detail)
} }
} }
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) { func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil) got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, 1, [3]float64{}, true, nil)
if got.Status != StatusWarn { if got.Status != StatusWarn {
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail) t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
} }
if got.Detail != "1 cores · 1 threads" {
t.Errorf("detail = %q, want topology only (no warn prose)", got.Detail)
}
if got.HasLoad { if got.HasLoad {
t.Error("a missing load average should not be reported as zeros") t.Error("a missing load average should not be reported as zeros")
} }
@@ -84,17 +90,44 @@ func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false}, {"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true}, {"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
} { } {
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil) got := cpuUsage(c.prev, c.cur, 2, 2, [3]float64{}, c.fresh, nil)
if got.Measured || got.Status != StatusUnknown { if got.Measured || got.Status != StatusUnknown {
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status) t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
} }
} }
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown { if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status) t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
} }
} }
func TestReadCPUTopology(t *testing.T) {
dir := t.TempDir()
const cpuinfo = `processor : 0
physical id : 0
core id : 0
processor : 1
physical id : 0
core id : 0
processor : 2
physical id : 0
core id : 1
processor : 3
physical id : 0
core id : 1
`
if err := os.WriteFile(filepath.Join(dir, "cpuinfo"), []byte(cpuinfo), 0o600); err != nil {
t.Fatal(err)
}
cores, threads := readCPUTopology(dir)
if cores != 2 || threads != 4 {
t.Errorf("topology = %d cores / %d threads, want 2/4", cores, threads)
}
}
func TestReadMemory(t *testing.T) { func TestReadMemory(t *testing.T) {
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample) dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
got := readMemory(dir) got := readMemory(dir)
@@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) {
if got.TotalBytes != 4*1024*1024*1024 { if got.TotalBytes != 4*1024*1024*1024 {
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText()) t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
} }
// 4 GiB total, 2 GiB available to new work: half used, cache included in // 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available).
// what is available.
if got.Percent() != 50 { if got.Percent() != 50 {
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText()) t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
} }
@@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
if !first.Memory.Measured { if !first.Memory.Measured {
t.Error("memory is a level, not a rate: it must be reported on the first sample") t.Error("memory is a level, not a rate: it must be reported on the first sample")
} }
if first.CPU.Cores != 2 { if first.CPU.Cores != 2 || first.CPU.Threads != 2 {
t.Errorf("cores = %d, want 2", first.CPU.Cores) t.Errorf("cores/threads = %d/%d, want 2/2", first.CPU.Cores, first.CPU.Threads)
}
if first.CPU.Detail != "2 cores · 2 threads" {
t.Errorf("detail = %q, want topology only", first.CPU.Detail)
} }
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 { if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
t.Errorf("load average not read: %+v", first.CPU.Load) t.Errorf("load average not read: %+v", first.CPU.Load)
+1 -1
View File
@@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket {
default: default:
s.Present = true s.Present = true
s.Status = StatusOK s.Status = StatusOK
s.Detail = "Listening." s.Detail = "Listening"
} }
return s return s
} }
+3 -4
View File
@@ -13,15 +13,11 @@ func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
data := h.statusBody() data := h.statusBody()
srv := h.dns.Server(h.cfg.Hostname, false)
data["Title"] = "SelfPost — status" data["Title"] = "SelfPost — status"
data["User"] = auth.CurrentUser(r) data["User"] = auth.CurrentUser(r)
data["Active"] = "status" data["Active"] = "status"
data["IsGlobal"] = true data["IsGlobal"] = true
data["Flash"] = statusFlash(r) data["Flash"] = statusFlash(r)
data["Hostname"] = h.cfg.Hostname
data["PTR"] = srv.PTR
h.view.Render(w, http.StatusOK, "status", data) h.view.Render(w, http.StatusOK, "status", data)
} }
@@ -68,6 +64,7 @@ func (h *Handlers) statusBody() map[string]any {
} }
machine := h.machine.Sample() machine := h.machine.Sample()
srv := h.dns.Server(h.cfg.Hostname, false)
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status) overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{ return map[string]any{
@@ -81,6 +78,8 @@ func (h *Handlers) statusBody() map[string]any {
"Cert": cert, "Cert": cert,
"Sockets": sockets, "Sockets": sockets,
"SocketStatus": socketStatus, "SocketStatus": socketStatus,
"Hostname": h.cfg.Hostname,
"PTR": srv.PTR,
"OverallStatus": overall, "OverallStatus": overall,
"OverallHeading": overallHeading(overall), "OverallHeading": overallHeading(overall),
} }
+13 -4
View File
@@ -440,10 +440,10 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
a link's blue and Sign out's red all carry through without a rule apiece. */ a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; } .nav .icon { width: 1rem; height: 1rem; flex: none; }
/* The current page's own sections, listed under the page entries. The domain /* The current page's own sections, listed under the page entries. The domain
page and the status page are eight and nine cards tall, and the only way to page is long enough that the only way to the card you came for was to scroll
the card you came for was to scroll past all the ones you did not. Only pages past all the ones you did not. Only pages that long carry an index it comes
that long carry an index it comes from the page's own "sections" template from the page's own "sections" template (see layout.html), so a page with
(see layout.html), so a page with two cards renders nothing here. */ two cards or a short paired Status layout renders nothing here. */
.sections { .sections {
display: flex; flex-direction: column; gap: 0.1rem; display: flex; flex-direction: column; gap: 0.1rem;
padding-top: 0.75rem; border-top: 1px solid var(--border); padding-top: 0.75rem; border-top: 1px solid var(--border);
@@ -550,12 +550,21 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1.2rem; margin-top: 1.2rem; gap: 1.2rem; margin-top: 1.2rem;
} }
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
grid those auto side margins absorb free space and shrink each card to its
content instead of filling the 1fr track so cancel them here, and allow
the cell to shrink below the card's intrinsic minimum when the column is
tight. */
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
/* The gap already spaces the cards inside the grid; the general .card + .card /* The gap already spaces the cards inside the grid; the general .card + .card
rule would add a second gap's worth on top of it, and only to the second rule would add a second gap's worth on top of it, and only to the second
card, which in one column reads as an uneven stack. The blocks around the card, which in one column reads as an uneven stack. The blocks around the
grid keep their spacing from it instead. */ grid keep their spacing from it instead. */
.split > .card + .card { margin-top: 0; } .split > .card + .card { margin-top: 0; }
.split + .card { margin-top: 1.2rem; } .split + .card { margin-top: 1.2rem; }
/* Configuration sits outside the polled fragment; without this it would
touch the last .split inside #status-body (a nested .split is not a sibling). */
#status-body + .card { margin-top: 1.2rem; }
/* The subject heads a delivery's page, and it is the one heading in the panel /* The subject heads a delivery's page, and it is the one heading in the panel
whose text we do not control: it may be a hundred characters with nothing to whose text we do not control: it may be a hundred characters with nothing to
break on. It wraps to as many lines as it needs (this is the page's name, not break on. It wraps to as many lines as it needs (this is the page's name, not
+2 -45
View File
@@ -1,5 +1,5 @@
{{/* Wide enough for mail queue and TLS certificate side by side (see .split {{/* Wide so Machine|Processes and the other .split pairs fill the column
in panel.css, as on account and delivery). */}} rather than the 48rem reading measure. */}}
{{define "wide"}}wide{{end}} {{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
@@ -9,30 +9,6 @@
{{template "status_body" .}} {{template "status_body" .}}
<div class="card" id="hostname">
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
<p class="muted">Receiving servers check that the name this server announces
resolves to its address <em>and</em> that the address resolves back to the same
name (forward-confirmed reverse DNS). A missing or mismatched reverse record is
the most common reason self-hosted mail is rejected or scored as spam. The
reverse record is set at the hosting provider, not in the domain's DNS zone.</p>
<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}}
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
<form class="inline" method="post" action="/status/recheck">
<button type="submit">Re-check DNS</button>
</form>
</div>
<div class="card" id="configuration"> <div class="card" id="configuration">
<h2>Configuration</h2> <h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the <p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
@@ -45,22 +21,3 @@
</form> </form>
</div> </div>
{{end}} {{end}}
{{/* The status page's section index, shown in the navigation column (see the
"sections" block in layout.html). The first six cards are the ones the
polling fragment replaces on a timer; their ids are part of
status_body.html and do not change with the reading, so the links here hold
across a refresh. */}}
{{define "sections"}}
<div class="sections">
<p class="sections-title">On this page</p>
<a href="#overall">Overall</a>
<a href="#processes">Processes</a>
<a href="#machine">Machine</a>
<a href="#queue">Mail queue</a>
<a href="#certificate">TLS certificate</a>
<a href="#sockets">Milter sockets</a>
<a href="#hostname">Hostname and reverse DNS</a>
<a href="#configuration">Configuration</a>
</div>
{{end}}
+96 -81
View File
@@ -5,71 +5,69 @@
<p class="muted">{{.OverallHeading}}</p> <p class="muted">{{.OverallHeading}}</p>
</div> </div>
<div class="card" id="processes"> <div class="split">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2> <div class="card" id="machine">
{{if .ProcessError}} <h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
<p class="error">Could not ask supervisord for the process list.</p> <table>
{{else}} <thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<table> <tbody>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead> <tr>
<tbody> <td class="metric">CPU</td>
{{range .Processes}} <td class="metric">
<tr> {{if .Machine.CPU.Measured}}
<td>{{.Name}}</td> <meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
<td><span class="st st-{{.Status}}">{{.State}}</span></td> {{.Machine.CPU.BusyText}}
<td class="muted">{{.Detail}}</td> {{else}}<span class="muted"></span>{{end}}
</tr> </td>
{{end}} <td class="muted">{{.Machine.CPU.Detail}}</td>
</tbody> </tr>
</table> <tr>
{{end}} <td class="metric">Memory</td>
</div> <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="machine"> <div class="card" id="processes">
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2> <h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
<p class="muted">Processor, memory and network of the machine this container {{if .ProcessError}}
runs on, read from the kernel's counters. CPU and throughput are rates, so <p class="error">Could not ask supervisord for the process list.</p>
they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}} {{else}}
since the previous refresh rather than this instant.</p> <table>
<table> <thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead> <tbody>
<tbody> {{range .Processes}}
<tr> <tr>
<td class="metric">CPU</td> <td>{{.Name}}</td>
<td class="metric"> <td><span class="st st-{{.Status}}">{{.State}}</span></td>
{{if .Machine.CPU.Measured}} <td class="muted">{{.Detail}}</td>
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter> </tr>
{{.Machine.CPU.BusyText}} {{end}}
{{else}}<span class="muted"></span>{{end}} </tbody>
</td> </table>
<td class="muted">{{.Machine.CPU.Detail}}</td> {{end}}
</tr> </div>
<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{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
{{end}}
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
</td>
</tr>
</tbody>
</table>
</div> </div>
<div class="split"> <div class="split">
@@ -79,14 +77,12 @@
<p class="error">{{.QueueError}}</p> <p class="error">{{.QueueError}}</p>
{{else}} {{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p> <p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
<a class="btn" href="/mail-queue">Full queue</a> <a class="btn" href="/mail-queue">View queue</a>
{{end}} {{end}}
</div> </div>
<div class="card" id="certificate"> <div class="card" id="certificate">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2> <h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
{{if not .Cert.NotAfter.IsZero}} {{if not .Cert.NotAfter.IsZero}}
<label>Expires</label> <label>Expires</label>
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span> <span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
@@ -95,20 +91,39 @@
</div> </div>
</div> </div>
<div class="card" id="sockets"> <div class="split">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2> <div class="card" id="sockets">
<table> <h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead> <table>
<tbody> <thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
{{range .Sockets}} <tbody>
<tr> {{range .Sockets}}
<td>{{.Name}}</td> <tr>
<td class="muted">{{.Path}}</td> <td>{{.Name}}</td>
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td> <td><span class="st st-{{.Status}}">{{.Status}}</span></td>
</tr> <td class="muted">{{.Detail}}</td>
{{end}} </tr>
</tbody> {{end}}
</table> </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="/status/recheck">
<button type="submit">Re-check DNS</button>
</form>
</div>
</div> </div>
</div> </div>
{{end}} {{end}}
+32 -13
View File
@@ -41,9 +41,9 @@ func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
// Anchors the index links to, taken from the page's own cards. // Anchors the index links to, taken from the page's own cards. Status used
// to carry one too; the paired layout is short enough without it.
wantAnchors := map[string]string{ wantAnchors := map[string]string{
"status": `href="#certificate"`,
"domain_detail": `href="#danger"`, "domain_detail": `href="#danger"`,
} }
for name, page := range engine.Pages() { for name, page := range engine.Pages() {
@@ -72,9 +72,10 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
// The pages that carry an index; both are checked with a credential shown, // The pages that carry an index; checked with a credential shown, which is
// which is the domain page's one conditional entry. // the domain page's one conditional entry. Status dropped its index once
for _, name := range []string{"status", "domain_detail"} { // the paired layout was short enough.
for _, name := range []string{"domain_detail"} {
var index bytes.Buffer var index bytes.Buffer
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err) t.Fatalf("execute sections for %q: %v", name, err)
@@ -379,20 +380,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com", "opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
"203.0.113.10 → no PTR record", `action="/reload"`, "203.0.113.10 → no PTR record", `action="/reload"`,
`hx-get="/status/fragment"`, `class="st st-error"`, `hx-get="/status/fragment"`, `class="st st-error"`,
// Queue and certificate sit abreast inside the polled fragment; their // Three .split rows inside the polled fragment: machine|processes,
// ids stay on the cards so the section index and scroll highlights hold. // queue|certificate, and sockets|hostname. Ids stay on the cards.
`class="split"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
`action="/status/recheck"`,
// The machine card: the bars carry their reading in an attribute // The machine card: the bars carry their reading in an attribute
// (the CSP rules out sizing them with a style), and the figures are // (the CSP rules out sizing them with a style), and the figures are
// printed beside them for anything that does not render a meter. // printed beside them for anything that does not render a meter.
`<meter value="12"`, `<meter value="50"`, `<meter value="12"`, `<meter value="50"`,
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB", "4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
"eth0: 1.0 MiB in, 512.0 KiB out", "eth0: 1.0 MiB in, 512.0 KiB out",
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("status page is missing %q", 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="/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 // A machine whose counters could not be read — no /proc, or a first reading
@@ -456,13 +475,13 @@ func statusPageData() map[string]any {
}, },
"Machine": health.Machine{ "Machine": health.Machine{
CPU: health.CPU{ CPU: health.CPU{
Measured: true, BusyPct: 12.4, Cores: 4, Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true, Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19", Status: health.StatusOK, Detail: "4 cores · 4 threads",
}, },
Memory: health.Memory{ Memory: health.Memory{
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50, Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.", Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
}, },
Network: health.Network{ Network: health.Network{
Measured: true, RxRate: 2048, TxRate: 1024, Measured: true, RxRate: 2048, TxRate: 1024,
@@ -475,7 +494,7 @@ func statusPageData() map[string]any {
Status: health.StatusOK, Status: health.StatusOK,
}, },
"Sockets": []health.Socket{ "Sockets": []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."}, {Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
}, },
"SocketStatus": health.StatusOK, "SocketStatus": health.StatusOK,
"OverallStatus": health.StatusError, "OverallStatus": health.StatusError,