diff --git a/CHANGELOG.md b/CHANGELOG.md index b640fb6..5f600b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed -- panel: **Status** places Mail queue and TLS certificate side by side (the - same `.split` / wide layout as Settings and a delivery), so the shorter - checks take less vertical space; Processes, Machine, sockets, hostname, and - Configuration stay full width. +- panel: **Status** is wide again so paired cards fill the column. Layout: + Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖ + Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS + 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 +- 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 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 diff --git a/docs/guide.md b/docs/guide.md index fbc8dcb..d7ac31b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -189,7 +189,7 @@ service healthy and will mail be accepted?" - **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel), TLS certificate validity and expiry, milter socket presence, and a short 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 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 3641ed0..211cd12 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,6 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | contributing | `CONTRIBUTING.md` | candidate | — | | 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) | +| panel-docs | In-panel operator documentation | candidate | — | **Recommended order** (not binding): **inbound-relay** first among agreed 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. **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. + diff --git a/internal/health/machine.go b/internal/health/machine.go index 37f5b5b..0346b16 100644 --- a/internal/health/machine.go +++ b/internal/health/machine.go @@ -62,10 +62,14 @@ type CPU struct { // nothing until it is true. Measured bool 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 - // could be read. Unlike BusyPct it needs no previous sample, so it is - // there on the very first page load. + // could be read. Unlike BusyPct it needs no previous sample. It is kept + // for the sampler but is no longer shown on the Status card. Load [3]float64 HasLoad bool Status Status @@ -176,7 +180,14 @@ type netCounters struct { func (m *MachineSampler) Sample() Machine { root := m.root() 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) m.mu.Lock() @@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine { fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow mach := Machine{ - CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr), + CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr), Memory: readMemory(root), Network: networkUsage(prevNet, netNow, window, fresh, netErr), } @@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine { return mach } -// cpuUsage grades the processor over the window. The load average is reported -// alongside it because the two answer different questions — how busy the -// processor was, and how many tasks were waiting for it — and a machine can -// look idle while work queues up behind a slow disk. -func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU { - c := CPU{Cores: cores} +// cpuUsage grades the processor over the window. Detail carries only the +// core and thread counts — load average and busy prose stay out of the Status +// card (see roadmap panel-docs for operator-facing explanation later). +func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU { + c := CPU{Cores: cores, Threads: threads} if !isZeroLoad(load) { 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." return c } + c.Detail = c.contextText() if !fresh || cur.total <= prev.total { c.Status = StatusUnknown - c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText()) return c } @@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er if c.BusyPct >= cpuWarnPct { c.Status = StatusWarn - c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.") } else { c.Status = StatusOK - c.Detail = c.contextText() } return c } -// contextText is the CPU's supporting figures: what the percentage is a -// percentage of, and how deep the run queue is. +// contextText is the CPU detail column: physical cores and logical threads. func (c CPU) contextText() string { - var parts []string - if c.Cores > 0 { - parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores)) + switch { + case c.Cores > 0 && c.Threads > 0: + 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 @@ -296,8 +305,7 @@ func readMemory(root string) Memory { m.SwapUsedBytes = m.SwapTotalBytes - swapFree } - detail := fmt.Sprintf("%s used of %s; %s available to new work.", - humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available)) + detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total)) if m.SwapTotalBytes > 0 { 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 } +// 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 // /proc/loadavg simply has none reported, so the failure is a zero value rather // than an error. @@ -544,14 +620,3 @@ func humanRate(perSec float64) string { } 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, " ") -} diff --git a/internal/health/machine_test.go b/internal/health/machine_test.go index e8e3db6..219871f 100644 --- a/internal/health/machine_test.go +++ b/internal/health/machine_test.go @@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) { // 1000 ticks pass, 250 of them idle: 75% busy. prev := cpuTimes{total: 10000, idle: 8000} 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 { t.Fatalf("reading not marked measured: %+v", got) } @@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) { if got.Status != StatusOK { t.Errorf("status = %q, want ok", got.Status) } - if got.Cores != 4 || !got.HasLoad { - t.Errorf("cores/load not reported: %+v", got) + if got.Cores != 4 || got.Threads != 8 || !got.HasLoad { + 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) { - 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 { 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 { 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}, {"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 { 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) } } +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) { 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) @@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) { if got.TotalBytes != 4*1024*1024*1024 { 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 - // what is available. + // 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available). if got.Percent() != 50 { t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText()) } @@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) { if !first.Memory.Measured { t.Error("memory is a level, not a rate: it must be reported on the first sample") } - if first.CPU.Cores != 2 { - t.Errorf("cores = %d, want 2", first.CPU.Cores) + if first.CPU.Cores != 2 || first.CPU.Threads != 2 { + 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 { t.Errorf("load average not read: %+v", first.CPU.Load) diff --git a/internal/health/socket.go b/internal/health/socket.go index 04cfcc0..621ebfd 100644 --- a/internal/health/socket.go +++ b/internal/health/socket.go @@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket { default: s.Present = true s.Status = StatusOK - s.Detail = "Listening." + s.Detail = "Listening" } return s } diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index b1079bf..2518116 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -13,15 +13,11 @@ func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) { return } data := h.statusBody() - srv := h.dns.Server(h.cfg.Hostname, false) - data["Title"] = "SelfPost — status" data["User"] = auth.CurrentUser(r) data["Active"] = "status" data["IsGlobal"] = true data["Flash"] = statusFlash(r) - data["Hostname"] = h.cfg.Hostname - data["PTR"] = srv.PTR h.view.Render(w, http.StatusOK, "status", data) } @@ -68,6 +64,7 @@ func (h *Handlers) statusBody() 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{ @@ -81,6 +78,8 @@ func (h *Handlers) statusBody() map[string]any { "Cert": cert, "Sockets": sockets, "SocketStatus": socketStatus, + "Hostname": h.cfg.Hostname, + "PTR": srv.PTR, "OverallStatus": overall, "OverallHeading": overallHeading(overall), } diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 8140cab..4a2b2e4 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -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. */ .nav .icon { width: 1rem; height: 1rem; flex: none; } /* 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 - the card you came for was to scroll past all the ones you did not. Only pages - that long carry an index — it comes from the page's own "sections" template - (see layout.html), so a page with two cards renders nothing here. */ + page is long enough that the only way to the card you came for was to scroll + past all the ones you did not. Only pages that long carry an index — it comes + from the page's own "sections" template (see layout.html), so a page with + two cards or a short paired Status layout renders nothing here. */ .sections { display: flex; flex-direction: column; gap: 0.1rem; 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)); 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 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 grid keep their spacing from it instead. */ .split > .card + .card { margin-top: 0; } .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 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 diff --git a/internal/web/view/templates/status.html b/internal/web/view/templates/status.html index d0b4e9e..bf6353d 100644 --- a/internal/web/view/templates/status.html +++ b/internal/web/view/templates/status.html @@ -1,5 +1,5 @@ -{{/* Wide enough for mail queue and TLS certificate side by side (see .split - in panel.css, as on account and delivery). */}} +{{/* Wide so Machine|Processes and the other .split pairs fill the column + rather than the 48rem reading measure. */}} {{define "wide"}}wide{{end}} {{define "content"}} @@ -9,30 +9,6 @@ {{template "status_body" .}} -
Receiving servers check that the name this server announces - resolves to its address and 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.
- - - {{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}} - - {{if .PTR.Records}} - - {{range .PTR.Records}}{{.}} -{{end}} - {{end}} - -{{.PTR.Detail}}
- - -Regenerates the OpenDKIM and Postfix configuration from the @@ -45,22 +21,3 @@
On this page
- Overall - Processes - Machine - Mail queue - TLS certificate - Milter sockets - Hostname and reverse DNS - Configuration -{{.OverallHeading}}
-Could not ask supervisord for the process list.
- {{else}} -| Program | State | Detail |
|---|---|---|
| {{.Name}} | -{{.State}} | -{{.Detail}} | -
| Resource | Usage | Detail |
|---|---|---|
| CPU | +
+ {{if .Machine.CPU.Measured}}
+ |
+ {{.Machine.CPU.Detail}} | +
| Memory | +
+ {{if .Machine.Memory.Measured}}
+ |
+ {{.Machine.Memory.Detail}} | +
| Network | +
+ {{if .Machine.Network.Measured}}
+ ↓ {{.Machine.Network.InRateText}} ↑ {{.Machine.Network.OutRateText}} + {{else}}—{{end}} + |
+
+ {{range .Machine.Network.Interfaces}}
+ {{.Name}}: {{.InText}} in, {{.OutText}} out
+ {{end}}
+ {{if .Machine.Network.Detail}}{{.Machine.Network.Detail}} {{end}}
+ |
+
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.
-| Resource | Usage | Detail |
|---|---|---|
| CPU | -
- {{if .Machine.CPU.Measured}}
- |
- {{.Machine.CPU.Detail}} | -
| Memory | -
- {{if .Machine.Memory.Measured}}
- |
- {{.Machine.Memory.Detail}} | -
| Network | -
- {{if .Machine.Network.Measured}}
- ↓ {{.Machine.Network.InRateText}} ↑ {{.Machine.Network.OutRateText}} - {{else}}—{{end}} - |
-
- {{range .Machine.Network.Interfaces}}
- {{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}
- {{end}}
- {{if .Machine.Network.Detail}}{{.Machine.Network.Detail}} {{end}}
- |
-
Could not ask supervisord for the process list.
+ {{else}} +| Program | State | Detail |
|---|---|---|
| {{.Name}} | +{{.State}} | +{{.Detail}} | +
{{.QueueError}}
{{else}}{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}
- Full queue + View queue {{end}}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.
{{if not .Cert.NotAfter.IsZero}} {{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}} @@ -95,20 +91,39 @@| Milter | Socket | State |
|---|---|---|
| {{.Name}} | -{{.Path}} | -{{.Status}} {{.Detail}} | -
| Milter | State | Detail |
|---|---|---|
| {{.Name}} | +{{.Status}} | +{{.Detail}} | +