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:
+17
-4
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+100
-35
@@ -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 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
|
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, " ")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
|
|||||||
@@ -5,32 +5,9 @@
|
|||||||
<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>
|
|
||||||
{{if .ProcessError}}
|
|
||||||
<p class="error">Could not ask supervisord for the process list.</p>
|
|
||||||
{{else}}
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Processes}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Name}}</td>
|
|
||||||
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
|
||||||
<td class="muted">{{.Detail}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="machine">
|
<div class="card" id="machine">
|
||||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||||
<p class="muted">Processor, memory and network of the machine this container
|
|
||||||
runs on, read from the kernel's counters. CPU and throughput are rates, so
|
|
||||||
they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}}
|
|
||||||
since the previous refresh rather than this instant.</p>
|
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -63,7 +40,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="muted">
|
<td class="muted">
|
||||||
{{range .Machine.Network.Interfaces}}
|
{{range .Machine.Network.Interfaces}}
|
||||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
|
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||||
</td>
|
</td>
|
||||||
@@ -72,6 +49,27 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="processes">
|
||||||
|
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
||||||
|
{{if .ProcessError}}
|
||||||
|
<p class="error">Could not ask supervisord for the process list.</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Processes}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
||||||
|
<td class="muted">{{.Detail}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="queue">
|
<div class="card" id="queue">
|
||||||
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
||||||
@@ -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="split">
|
||||||
<div class="card" id="sockets">
|
<div class="card" id="sockets">
|
||||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Sockets}}
|
{{range .Sockets}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Name}}</td>
|
<td>{{.Name}}</td>
|
||||||
<td class="muted">{{.Path}}</td>
|
<td><span class="st st-{{.Status}}">{{.Status}}</span></td>
|
||||||
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
|
<td class="muted">{{.Detail}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user