93cf1de3b7
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>
623 lines
18 KiB
Go
623 lines
18 KiB
Go
package health
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// machineSampleWindow is the longest gap between two readings that still counts
|
||
// as "now". CPU busy time and network throughput are differences between two
|
||
// samples, and the status fragment polls every five seconds — but a page opened
|
||
// after the panel sat idle for an hour would otherwise average that whole hour
|
||
// and present it as the current load. Past this the reading only re-baselines,
|
||
// and the rates say they are still being measured.
|
||
const machineSampleWindow = time.Minute
|
||
|
||
// Thresholds for the two resources whose exhaustion actually threatens mail: a
|
||
// fully busy processor slows queue processing, and a machine out of memory has
|
||
// its processes killed. Network throughput has no comparable threshold — what
|
||
// counts as a lot depends entirely on the link — so it is reported, not graded.
|
||
const (
|
||
cpuWarnPct = 90.0
|
||
memWarnPct = 90.0
|
||
memErrorPct = 97.0
|
||
)
|
||
|
||
// Machine is the resource usage of the machine this container runs on: the
|
||
// status page's answer to "is the server itself under strain", next to the
|
||
// component checks that answer "is mail flowing".
|
||
//
|
||
// The numbers come from the kernel's /proc filesystem, which a container shares
|
||
// with its host unless it was started with its own namespaces — so CPU and
|
||
// memory describe the host, while /proc/net/dev describes whatever network
|
||
// namespace the container is in (its own veth under the default bridge, the
|
||
// host's interfaces under network_mode: host).
|
||
type Machine struct {
|
||
CPU CPU
|
||
Memory Memory
|
||
Network Network
|
||
// Window is the interval the rates were measured over; zero until a
|
||
// second reading exists.
|
||
Window time.Duration
|
||
Status Status
|
||
}
|
||
|
||
// WindowText names the sampling interval for the card's description.
|
||
func (m Machine) WindowText() string {
|
||
if m.Window <= 0 {
|
||
return ""
|
||
}
|
||
return m.Window.Round(time.Second).String()
|
||
}
|
||
|
||
// CPU is processor load over the sampling window.
|
||
type CPU struct {
|
||
// Measured is false until two readings exist to compare; BusyPct means
|
||
// nothing until it is true.
|
||
Measured bool
|
||
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
|
||
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. It is kept
|
||
// for the sampler but is no longer shown on the Status card.
|
||
Load [3]float64
|
||
HasLoad bool
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
// Percent is BusyPct as a whole number, for the <meter> element's value
|
||
// attribute. The bar carries its value in an attribute rather than a width in a
|
||
// style attribute because the panel's CSP has no inline-style exemption
|
||
// (security.md).
|
||
func (c CPU) Percent() int { return percent(c.BusyPct) }
|
||
|
||
// BusyText is the reading as it appears beside the bar.
|
||
func (c CPU) BusyText() string { return fmt.Sprintf("%d%%", c.Percent()) }
|
||
|
||
// Memory is main memory (and swap, where the machine has any) at the moment of
|
||
// the reading. Unlike CPU and network it is a level, not a rate, so a single
|
||
// reading is enough and it is never in the "measuring" state.
|
||
type Memory struct {
|
||
Measured bool
|
||
TotalBytes uint64
|
||
AvailableBytes uint64
|
||
UsedBytes uint64
|
||
UsedPct float64
|
||
SwapTotalBytes uint64
|
||
SwapUsedBytes uint64
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
// Percent, UsedText and TotalText render the reading for the template; see
|
||
// CPU.Percent for why the bar's value travels as an attribute.
|
||
func (m Memory) Percent() int { return percent(m.UsedPct) }
|
||
func (m Memory) UsedText() string { return humanBytes(m.UsedBytes) }
|
||
func (m Memory) TotalText() string { return humanBytes(m.TotalBytes) }
|
||
func (m Memory) PctText() string { return fmt.Sprintf("%d%%", m.Percent()) }
|
||
|
||
// Interface is one network interface's traffic: the counters since the
|
||
// interface came up, and the throughput over the sampling window.
|
||
type Interface struct {
|
||
Name string
|
||
RxBytes uint64
|
||
TxBytes uint64
|
||
RxRate float64 // bytes per second, valid when the Network is Measured
|
||
TxRate float64
|
||
Measured bool
|
||
}
|
||
|
||
func (i Interface) InText() string { return humanBytes(i.RxBytes) }
|
||
func (i Interface) OutText() string { return humanBytes(i.TxBytes) }
|
||
func (i Interface) InRateText() string { return humanRate(i.RxRate) }
|
||
func (i Interface) OutRateText() string { return humanRate(i.TxRate) }
|
||
|
||
// Network is the traffic across every interface that has carried any, loopback
|
||
// excluded — loopback traffic is the container talking to itself (the panel to
|
||
// SQLite, Postfix to its milters) and says nothing about the link.
|
||
type Network struct {
|
||
Measured bool
|
||
Interfaces []Interface
|
||
RxRate float64
|
||
TxRate float64
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
func (n Network) InRateText() string { return humanRate(n.RxRate) }
|
||
func (n Network) OutRateText() string { return humanRate(n.TxRate) }
|
||
|
||
// MachineSampler reads those counters. Its zero value is ready to use and it is
|
||
// safe for concurrent use, but one sampler has to be shared by every caller:
|
||
// the rates are measured against the reading the previous call left behind, so
|
||
// a fresh sampler per request would never have anything to compare against.
|
||
type MachineSampler struct {
|
||
// procRoot replaces /proc in tests; empty means the real one.
|
||
procRoot string
|
||
|
||
mu sync.Mutex
|
||
prevAt time.Time
|
||
prevCPU cpuTimes
|
||
prevNet map[string]netCounters
|
||
}
|
||
|
||
func (m *MachineSampler) root() string {
|
||
if m.procRoot == "" {
|
||
return "/proc"
|
||
}
|
||
return m.procRoot
|
||
}
|
||
|
||
// cpuTimes is the aggregate of /proc/stat's "cpu" line: all time accounted for,
|
||
// and the part of it the processor spent doing nothing.
|
||
type cpuTimes struct {
|
||
total uint64
|
||
idle uint64
|
||
}
|
||
|
||
// netCounters is one interface's byte counters from /proc/net/dev.
|
||
type netCounters struct {
|
||
rx uint64
|
||
tx uint64
|
||
}
|
||
|
||
// Sample reads the current counters and reports usage since the previous call.
|
||
// Like every other check here it never fails: a counter that cannot be read
|
||
// becomes an unknown status with an explanation, so a kernel that does not
|
||
// publish one of these files (or a panel run outside Linux for development)
|
||
// costs one line of the card rather than the page.
|
||
func (m *MachineSampler) Sample() Machine {
|
||
root := m.root()
|
||
now := time.Now()
|
||
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()
|
||
prevAt, prevCPU, prevNet := m.prevAt, m.prevCPU, m.prevNet
|
||
if cpuErr == nil {
|
||
m.prevCPU = cpuNow
|
||
}
|
||
if netErr == nil {
|
||
m.prevNet = netNow
|
||
}
|
||
if cpuErr == nil || netErr == nil {
|
||
m.prevAt = now
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
// A window of zero (two calls in the same instant) would divide by zero;
|
||
// one longer than machineSampleWindow is no longer a description of now.
|
||
window := now.Sub(prevAt)
|
||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||
|
||
mach := Machine{
|
||
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
|
||
Memory: readMemory(root),
|
||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||
}
|
||
if fresh {
|
||
mach.Window = window
|
||
}
|
||
mach.Status = Worst(mach.CPU.Status, mach.Memory.Status, mach.Network.Status)
|
||
return mach
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if err != nil {
|
||
c.Status = StatusUnknown
|
||
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
|
||
return c
|
||
}
|
||
|
||
total := cur.total - prev.total
|
||
idle := uint64(0)
|
||
if cur.idle > prev.idle {
|
||
idle = cur.idle - prev.idle
|
||
}
|
||
if idle > total {
|
||
idle = total
|
||
}
|
||
c.Measured = true
|
||
c.BusyPct = 100 * float64(total-idle) / float64(total)
|
||
|
||
if c.BusyPct >= cpuWarnPct {
|
||
c.Status = StatusWarn
|
||
} else {
|
||
c.Status = StatusOK
|
||
}
|
||
return c
|
||
}
|
||
|
||
// contextText is the CPU detail column: physical cores and logical threads.
|
||
func (c CPU) contextText() string {
|
||
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 ""
|
||
}
|
||
}
|
||
|
||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
|
||
// MemFree on a healthy machine is near zero and would report a permanent
|
||
// emergency. MemAvailable is the kernel's own estimate of what a new workload
|
||
// could actually get.
|
||
func readMemory(root string) Memory {
|
||
var m Memory
|
||
fields, err := readMeminfo(root)
|
||
if err != nil {
|
||
m.Status = StatusUnknown
|
||
m.Detail = "The kernel's memory counters (/proc/meminfo) could not be read here."
|
||
return m
|
||
}
|
||
total, available := fields["MemTotal"], fields["MemAvailable"]
|
||
if total == 0 {
|
||
m.Status = StatusUnknown
|
||
m.Detail = "/proc/meminfo does not report a total memory size."
|
||
return m
|
||
}
|
||
if available > total {
|
||
available = total
|
||
}
|
||
|
||
m.Measured = true
|
||
m.TotalBytes = total
|
||
m.AvailableBytes = available
|
||
m.UsedBytes = total - available
|
||
m.UsedPct = 100 * float64(m.UsedBytes) / float64(total)
|
||
m.SwapTotalBytes = fields["SwapTotal"]
|
||
if swapFree := fields["SwapFree"]; m.SwapTotalBytes > swapFree {
|
||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||
}
|
||
|
||
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))
|
||
}
|
||
switch {
|
||
case m.UsedPct >= memErrorPct:
|
||
m.Status = StatusError
|
||
m.Detail = detail + " Memory is exhausted; the kernel kills processes to reclaim it, and Postfix or the panel are candidates."
|
||
case m.UsedPct >= memWarnPct:
|
||
m.Status = StatusWarn
|
||
m.Detail = detail + " Little headroom left."
|
||
default:
|
||
m.Status = StatusOK
|
||
m.Detail = detail
|
||
}
|
||
return m
|
||
}
|
||
|
||
// networkUsage turns two readings of the interface counters into throughput.
|
||
// It never grades: there is no usage figure that is wrong for a mail server, so
|
||
// the row is informational and only reports "unknown" when the counters are
|
||
// unreadable.
|
||
func networkUsage(prev, cur map[string]netCounters, window time.Duration, fresh bool, err error) Network {
|
||
var n Network
|
||
if err != nil {
|
||
n.Status = StatusUnknown
|
||
n.Detail = "The kernel's network counters (/proc/net/dev) could not be read here."
|
||
return n
|
||
}
|
||
|
||
names := make([]string, 0, len(cur))
|
||
for name := range cur {
|
||
names = append(names, name)
|
||
}
|
||
// Map order is random, and this table is re-rendered every few seconds:
|
||
// without a sort the rows would shuffle under the reader.
|
||
sort.Strings(names)
|
||
|
||
n.Measured = fresh
|
||
for _, name := range names {
|
||
c := cur[name]
|
||
// An interface that has never carried a byte is a veth or a bridge
|
||
// the deployment happens to have, not part of the mail path.
|
||
if c.rx == 0 && c.tx == 0 {
|
||
continue
|
||
}
|
||
iface := Interface{Name: name, RxBytes: c.rx, TxBytes: c.tx, Measured: fresh}
|
||
if fresh {
|
||
p := prev[name]
|
||
secs := window.Seconds()
|
||
// Counters only go up; a drop means the interface (or the
|
||
// container) was recreated, so there is no rate to report.
|
||
if c.rx >= p.rx {
|
||
iface.RxRate = float64(c.rx-p.rx) / secs
|
||
}
|
||
if c.tx >= p.tx {
|
||
iface.TxRate = float64(c.tx-p.tx) / secs
|
||
}
|
||
n.RxRate += iface.RxRate
|
||
n.TxRate += iface.TxRate
|
||
}
|
||
n.Interfaces = append(n.Interfaces, iface)
|
||
}
|
||
|
||
n.Status = StatusOK
|
||
switch {
|
||
case len(n.Interfaces) == 0:
|
||
n.Detail = "No interface outside loopback has carried any traffic."
|
||
case !fresh:
|
||
n.Detail = "Measuring — this reading sets the baseline; the next refresh has the throughput."
|
||
}
|
||
return n
|
||
}
|
||
|
||
// readCPUTimes returns the aggregate processor times and the number of cores
|
||
// from /proc/stat. The times are in USER_HZ ticks, which cancel out because
|
||
// only their ratio is used.
|
||
func readCPUTimes(root string) (cpuTimes, int, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "stat"))
|
||
if err != nil {
|
||
return cpuTimes{}, 0, err
|
||
}
|
||
var (
|
||
times cpuTimes
|
||
cores int
|
||
found bool
|
||
)
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||
continue
|
||
}
|
||
if fields[0] != "cpu" {
|
||
cores++ // cpu0, cpu1, … — one line per core
|
||
continue
|
||
}
|
||
// user nice system idle iowait irq softirq steal (guest fields are
|
||
// already counted inside user/nice, so they are left out).
|
||
for i, f := range fields[1:] {
|
||
if i >= 8 {
|
||
break
|
||
}
|
||
v, err := strconv.ParseUint(f, 10, 64)
|
||
if err != nil {
|
||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: unreadable cpu time %q", f)
|
||
}
|
||
times.total += v
|
||
// idle plus iowait: both are time the processor had nothing to
|
||
// run, and separating them tells the reader nothing here.
|
||
if i == 3 || i == 4 {
|
||
times.idle += v
|
||
}
|
||
}
|
||
found = true
|
||
}
|
||
if !found {
|
||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: no aggregate cpu line")
|
||
}
|
||
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.
|
||
func readLoadAvg(root string) [3]float64 {
|
||
var load [3]float64
|
||
data, err := os.ReadFile(filepath.Join(root, "loadavg"))
|
||
if err != nil {
|
||
return load
|
||
}
|
||
fields := strings.Fields(string(data))
|
||
if len(fields) < 3 {
|
||
return load
|
||
}
|
||
for i := range load {
|
||
v, err := strconv.ParseFloat(fields[i], 64)
|
||
if err != nil {
|
||
return [3]float64{}
|
||
}
|
||
load[i] = v
|
||
}
|
||
return load
|
||
}
|
||
|
||
func isZeroLoad(load [3]float64) bool {
|
||
return load == [3]float64{}
|
||
}
|
||
|
||
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
|
||
// The file reports kB (kibibytes, despite the label) for these fields.
|
||
func readMeminfo(root string) (map[string]uint64, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
want := map[string]bool{"MemTotal": true, "MemAvailable": true, "SwapTotal": true, "SwapFree": true}
|
||
out := make(map[string]uint64, len(want))
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
name, rest, ok := strings.Cut(line, ":")
|
||
if !ok || !want[name] {
|
||
continue
|
||
}
|
||
fields := strings.Fields(rest)
|
||
if len(fields) == 0 {
|
||
continue
|
||
}
|
||
v, err := strconv.ParseUint(fields[0], 10, 64)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
|
||
v *= 1024
|
||
}
|
||
out[name] = v
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, fmt.Errorf("/proc/meminfo: no readable fields")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
|
||
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
|
||
// name is separated by a colon, which may or may not have a space after it, so
|
||
// the split is on the colon and not on whitespace.
|
||
func readNetDev(root string) (map[string]netCounters, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make(map[string]netCounters)
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
name, rest, ok := strings.Cut(line, ":")
|
||
name = strings.TrimSpace(name)
|
||
if !ok || name == "" || name == "lo" || strings.Contains(name, " ") {
|
||
continue // header lines carry no name, or several words
|
||
}
|
||
fields := strings.Fields(rest)
|
||
if len(fields) < 9 {
|
||
continue
|
||
}
|
||
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
|
||
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
|
||
if err1 != nil || err2 != nil {
|
||
continue
|
||
}
|
||
out[name] = netCounters{rx: rx, tx: tx}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// percent rounds a 0–100 reading to a whole number and clamps it, so a bar's
|
||
// value attribute is always inside the range the element declares.
|
||
func percent(v float64) int {
|
||
switch {
|
||
case v <= 0:
|
||
return 0
|
||
case v >= 100:
|
||
return 100
|
||
default:
|
||
return int(v + 0.5)
|
||
}
|
||
}
|
||
|
||
// humanBytes renders a byte count in the binary units memory and traffic are
|
||
// conventionally read in.
|
||
func humanBytes(b uint64) string {
|
||
const unit = 1024
|
||
if b < unit {
|
||
return fmt.Sprintf("%d B", b)
|
||
}
|
||
div, exp := uint64(unit), 0
|
||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||
}
|
||
|
||
// humanRate renders throughput in bytes per second, to match the totals beside
|
||
// it rather than the bits per second a link is sold in.
|
||
func humanRate(perSec float64) string {
|
||
if perSec < 0 {
|
||
perSec = 0
|
||
}
|
||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||
}
|