Files
hamlab/internal/metrics/metrics.go
T
Mitchell-JohnsonandClaude Fable 5.1 4280b2226a Top: a hyper-thread wears its core's temperature, and the columns line up
The process table carries each logical CPU's core id from sysfs topology; a row with
no reading still has the temperature cells, so the % column is one column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:22:54 -07:00

1297 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package metrics samples host telemetry (CPU, memory, temps, fans, network, disks,
// filesystems, UPS) on a timer into an in-memory ring, for the Dashboard tab.
//
// Portability rules: everything comes from /proc and /sys, which every Linux kernel
// exposes the same way regardless of distro or architecture; nothing here needs
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
// (NUT) and apcaccess (apcupsd), tried in that order.
package metrics
import (
"bufio"
"context"
"errors"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
const (
DefaultInterval = 2 * time.Second
MinInterval = 100 * time.Millisecond
MaxInterval = 10 * time.Second
History = 300 // points kept per curve; the window below sets how far apart they are
// How far back the kept history reaches. The point count is fixed, so this sets the spacing:
// at a minute every sample is its own point, at a day each point is the average of ~5 minutes.
DefaultWindow = 10 * time.Minute
MinWindow = 1 * time.Minute
MaxWindow = 24 * time.Hour
fsEvery = 30 * time.Second // filesystem usage changes slowly and statfs on network mounts isn't free
upsEvery = 10 * time.Second // UPS daemons are an exec each; don't hammer them at fast intervals
sectorSize = 512 // /proc/diskstats always counts 512-byte sectors
)
type Mem struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Avail uint64 `json:"avail"`
Free uint64 `json:"free,omitempty"`
Cached uint64 `json:"cached,omitempty"`
Buffers uint64 `json:"buffers,omitempty"`
}
type Temp struct {
Chip string `json:"chip"`
Label string `json:"label"`
C float64 `json:"c"`
Max float64 `json:"max,omitempty"`
Crit float64 `json:"crit,omitempty"`
}
type Fan struct {
Chip string `json:"chip"`
Label string `json:"label"`
RPM int `json:"rpm"`
}
type Net struct {
Iface string `json:"iface"`
Up bool `json:"up"`
RxBps float64 `json:"rx_bps"`
TxBps float64 `json:"tx_bps"`
RxTotal uint64 `json:"rx_total"`
TxTotal uint64 `json:"tx_total"`
}
type Disk struct {
Name string `json:"name"`
Model string `json:"model,omitempty"`
Size uint64 `json:"size"`
ReadBps float64 `json:"read_bps"`
WriteBps float64 `json:"write_bps"`
UtilPct float64 `json:"util_pct"`
Temp float64 `json:"temp,omitempty"`
}
type FS struct {
Mount string `json:"mount"`
Type string `json:"type"`
Size uint64 `json:"size"`
Used uint64 `json:"used"`
}
type UPS struct {
Driver string `json:"driver"` // pwrstat | nut | apcupsd
Model string `json:"model"`
State string `json:"state"`
Source string `json:"source"`
OnBattery bool `json:"on_battery"`
InV float64 `json:"in_v"`
OutV float64 `json:"out_v"`
BatteryPct float64 `json:"battery_pct"`
RuntimeMin float64 `json:"runtime_min"`
LoadW float64 `json:"load_w"`
LoadPct float64 `json:"load_pct"`
LastEvent string `json:"last_event,omitempty"`
}
type Proc struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
Name string `json:"name"`
Cmd string `json:"cmd,omitempty"`
User string `json:"user"`
CPU float64 `json:"cpu"` // percent of one core
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
RSS uint64 `json:"rss"` // bytes
Threads int `json:"threads"`
State string `json:"state"`
Nice int `json:"nice"`
Started int64 `json:"started"` // unix ms
}
type Sample struct {
T int64 `json:"t"` // unix ms
Interval int64 `json:"interval_ms"` // sampling interval in effect
CPU float64 `json:"cpu"`
Cores []float64 `json:"cores"`
Load [3]float64 `json:"load"`
Mem Mem `json:"mem"`
Swap Mem `json:"swap"`
Temps []Temp `json:"temps"`
Fans []Fan `json:"fans"`
Net []Net `json:"net"`
Disks []Disk `json:"disks"`
FS []FS `json:"fs"`
UPS *UPS `json:"ups"`
Uptime float64 `json:"uptime"`
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
}
type Collector struct {
mu sync.RWMutex
ring []Sample // the kept history: one point per stride, each the average of its bucket
last Sample // the newest raw sample, whatever the stride
bucket []Sample // samples since the last point was kept
lastKept int64 // when that was, unix ms
interval time.Duration
window time.Duration
wake chan struct{} // nudges Run when the interval changes
prevCPU []cpuTimes
prevNet map[string][2]uint64
prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms
prevT time.Time
lastFS []FS
fsAt time.Time
lastUPS *UPS
upsAt time.Time
prevProc map[int]procTimes
procInfo map[int]procStatic
procAt time.Time
lastTable *ProcTable
upsProbe func() *UPS // nil when no UPS daemon is installed
subMu sync.Mutex
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
}
func NewCollector() *Collector {
c := &Collector{prevNet: map[string][2]uint64{}, prevDisk: map[string][3]uint64{},
interval: DefaultInterval, window: DefaultWindow, wake: make(chan struct{}, 1)}
switch {
case lookPath("pwrstat"):
c.upsProbe = upsPwrstat
case lookPath("upsc"):
c.upsProbe = upsNUT
case lookPath("apcaccess"):
c.upsProbe = upsAPC
}
return c
}
// Run samples until ctx is cancelled, at whatever interval is current.
func (c *Collector) Run(ctx context.Context) {
c.sample() // prime deltas
for {
// The wait is what's left of the interval after the work, so a machine whose
// sample takes 30 ms still delivers 4 a second at 250 ms, not 3.6.
wait := c.Interval() - time.Since(c.prevT)
if wait < 0 {
wait = 0
}
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return
case <-c.wake: // interval changed: restart the wait
t.Stop()
case <-t.C:
c.sample()
}
}
}
// SetInterval changes the sampling period, clamped to [MinInterval, MaxInterval].
func (c *Collector) SetInterval(d time.Duration) {
if d < MinInterval {
d = MinInterval
}
if d > MaxInterval {
d = MaxInterval
}
c.mu.Lock()
changed := d != c.interval
c.interval = d
c.mu.Unlock()
if changed {
select {
case c.wake <- struct{}{}:
default:
}
}
}
func (c *Collector) Interval() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.interval
}
// SetWindow sets how far back the kept history reaches, clamped to [MinWindow, MaxWindow].
func (c *Collector) SetWindow(d time.Duration) {
if d < MinWindow {
d = MinWindow
}
if d > MaxWindow {
d = MaxWindow
}
c.mu.Lock()
defer c.mu.Unlock()
c.window = d
c.trim()
}
func (c *Collector) Window() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.window
}
// Stride is how far apart the kept points are: the sampling interval until the window is
// wider than History samples of it, then the window split History ways.
func (c *Collector) Stride() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
return c.stride()
}
func (c *Collector) stride() time.Duration {
if s := c.window / History; s > c.interval {
return s
}
return c.interval
}
// keep folds samples into the history, one point per stride. Averaged, not sampled: a CPU
// graph of the last six hours should show the load through each five minutes, not whichever
// two-second instant happened to land on the mark.
func (c *Collector) keep(s Sample) {
c.bucket = append(c.bucket, s)
// A point is due a stride after the last one (for the first, a stride after the interval its
// first sample stands for). Half an interval of slack, because a timer never fires exactly on
// time: without it a tick that lands 5 ms early is "not yet", and every point runs one sample
// long. Measured from the last point kept, not by counting samples, so a machine that stalled
// or slept simply keeps the next sample it gets.
iv := c.interval.Milliseconds()
since := c.lastKept
if since == 0 {
since = c.bucket[0].T - iv
}
if s.T-since < c.stride().Milliseconds()-iv/2 {
return
}
c.lastKept = s.T
c.ring = append(c.ring, fold(c.bucket))
c.bucket = c.bucket[:0]
c.trim()
}
// trim drops points past the count budget and past the window. Caller holds c.mu.
func (c *Collector) trim() {
if len(c.ring) > History {
c.ring = c.ring[len(c.ring)-History:]
}
n := len(c.ring)
if n == 0 {
return
}
cut := c.ring[n-1].T - c.window.Milliseconds()
i := 0
for i < n && c.ring[i].T < cut {
i++
}
if i > 0 {
c.ring = c.ring[i:]
}
}
func (c *Collector) Latest() (Sample, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.last.T == 0 {
return Sample{}, false
}
return c.last, true
}
// ---- sampling ----
func (c *Collector) sample() {
now := time.Now()
dt := now.Sub(c.prevT).Seconds()
first := c.prevT.IsZero()
c.prevT = now
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
s.CPU, s.Cores = c.cpu()
s.Load = loadavg()
s.Mem, s.Swap = meminfo()
s.Uptime = uptime()
s.Temps, s.Fans = hwmon()
if len(s.Temps) == 0 {
s.Temps = thermalZones() // ARM SoCs and older kernels often expose only these
}
s.Net = c.net(dt)
s.Disks = c.disks(dt)
if c.lastFS == nil || now.Sub(c.fsAt) >= fsEvery {
c.lastFS, c.fsAt = filesystems(), now
}
s.FS, s.fsT = c.lastFS, c.fsAt.UnixMilli()
if c.upsProbe != nil && (c.upsAt.IsZero() || now.Sub(c.upsAt) >= upsEvery) {
c.lastUPS, c.upsAt = c.upsProbe(), now
}
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
if first {
return // deltas are meaningless on the priming pass
}
c.mu.Lock()
c.last = s
c.keep(s)
c.mu.Unlock()
c.subMu.Lock()
for ch := range c.subs {
select {
case ch <- struct{}{}:
default: // a slow listener misses a tick; it reads the latest on the next one
}
}
c.subMu.Unlock()
}
// Subscribe returns a channel that gets a nudge after every new sample, and the func
// that ends the subscription.
func (c *Collector) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
c.subMu.Lock()
if c.subs == nil {
c.subs = map[chan struct{}]struct{}{}
}
c.subs[ch] = struct{}{}
c.subMu.Unlock()
return ch, func() { c.subMu.Lock(); delete(c.subs, ch); c.subMu.Unlock() }
}
type cpuTimes struct{ busy, total uint64 }
func (c *Collector) cpu() (float64, []float64) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, nil
}
defer f.Close()
var cur []cpuTimes
sc := bufio.NewScanner(f)
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 8 || !strings.HasPrefix(fs[0], "cpu") {
continue
}
var vals [10]uint64
for i := 1; i < len(fs) && i <= 10; i++ {
vals[i-1], _ = strconv.ParseUint(fs[i], 10, 64)
}
idle := vals[3] + vals[4]
var total uint64
for _, v := range vals {
total += v
}
cur = append(cur, cpuTimes{busy: total - idle, total: total})
}
prev := c.prevCPU
c.prevCPU = cur
if len(prev) != len(cur) || len(cur) == 0 {
return 0, nil
}
pct := func(i int) float64 {
dt := float64(cur[i].total - prev[i].total)
if dt <= 0 {
return 0
}
return 100 * float64(cur[i].busy-prev[i].busy) / dt
}
cores := make([]float64, 0, len(cur)-1)
for i := 1; i < len(cur); i++ {
cores = append(cores, round1(pct(i)))
}
return round1(pct(0)), cores
}
func loadavg() (l [3]float64) {
b, err := os.ReadFile("/proc/loadavg")
if err != nil {
return
}
f := strings.Fields(string(b))
for i := 0; i < 3 && i < len(f); i++ {
l[i], _ = strconv.ParseFloat(f[i], 64)
}
return
}
func meminfo() (Mem, Mem) {
m := map[string]uint64{}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return Mem{}, Mem{}
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(v)
if len(f) == 0 {
continue
}
n, _ := strconv.ParseUint(f[0], 10, 64)
m[k] = n * 1024
}
mem := Mem{Total: m["MemTotal"], Avail: m["MemAvailable"], Free: m["MemFree"], Buffers: m["Buffers"], Cached: m["Cached"] + m["SReclaimable"]}
if mem.Avail == 0 { // pre-3.14 kernels: approximate
mem.Avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
mem.Used = mem.Total - mem.Avail
swap := Mem{Total: m["SwapTotal"], Avail: m["SwapFree"]}
swap.Used = swap.Total - swap.Avail
return mem, swap
}
func uptime() float64 {
b, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
if f := strings.Fields(string(b)); len(f) > 0 {
v, _ := strconv.ParseFloat(f[0], 64)
return v
}
return 0
}
// ---- temperatures and fans ----
var sensorRe = regexp.MustCompile(`^(temp|fan)(\d+)_input$`)
// tempLimit reads a *_max / *_crit file; values outside 0-200 °C are unset sentinels
// (NVMe reports ~65000 for absent thresholds).
func tempLimit(p string) float64 {
if v, ok := readInt(p); ok && v > 0 && v < 200000 {
return float64(v) / 1000
}
return 0
}
// hwmonDir reads every temp*/fan* sensor under one hwmon directory.
func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
chip = strings.TrimSpace(readStr(filepath.Join(d, "name")))
ents, err := os.ReadDir(d)
if err != nil {
return
}
for _, e := range ents {
m := sensorRe.FindStringSubmatch(e.Name())
if m == nil {
continue
}
base := m[1] + m[2]
raw, ok := readInt(filepath.Join(d, e.Name()))
if !ok {
continue
}
label := strings.TrimSpace(readStr(filepath.Join(d, base+"_label")))
if label == "" {
label = base
}
if m[1] == "temp" {
if raw < -60000 { // an unpowered sensor's sentinel (an idle audio amp reads -93 °C), not weather
continue
}
temps = append(temps, Temp{Chip: chip, Label: label, C: float64(raw) / 1000,
Max: tempLimit(filepath.Join(d, base+"_max")), Crit: tempLimit(filepath.Join(d, base+"_crit"))})
} else if raw > 0 || fileExists(filepath.Join(d, base+"_enable")) {
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
}
}
return
}
func hwmon() ([]Temp, []Fan) {
temps, fans := []Temp{}, []Fan{}
dirs, _ := filepath.Glob("/sys/class/hwmon/hwmon*")
for _, d := range dirs {
_, t, f := hwmonDir(d)
temps = append(temps, t...)
fans = append(fans, f...)
}
sort.SliceStable(temps, func(i, j int) bool {
if temps[i].Chip != temps[j].Chip {
return temps[i].Chip < temps[j].Chip
}
return temps[i].Label < temps[j].Label
})
return temps, fans
}
func thermalZones() []Temp {
out := []Temp{}
dirs, _ := filepath.Glob("/sys/class/thermal/thermal_zone*")
for _, d := range dirs {
raw, ok := readInt(filepath.Join(d, "temp"))
if !ok || raw <= 0 {
continue
}
typ := strings.TrimSpace(readStr(filepath.Join(d, "type")))
if typ == "" {
typ = filepath.Base(d)
}
t := Temp{Chip: "thermal", Label: typ, C: float64(raw) / 1000}
// trip points: trip_point_N_type in {critical, hot, passive, active}
trips, _ := filepath.Glob(filepath.Join(d, "trip_point_*_type"))
for _, tp := range trips {
kind := strings.TrimSpace(readStr(tp))
v := tempLimit(strings.TrimSuffix(tp, "_type") + "_temp")
switch kind {
case "critical":
t.Crit = v
case "hot", "passive":
if t.Max == 0 || v < t.Max {
t.Max = v
}
}
}
out = append(out, t)
}
return out
}
// blockTemp finds the drive's own temperature via the hwmon node the kernel attaches
// to the device: the nvme driver and drivetemp (SATA/SAS) both do this.
func blockTemp(name string) float64 {
for _, pat := range []string{"/sys/block/" + name + "/device/hwmon*", "/sys/block/" + name + "/device/hwmon/hwmon*"} {
dirs, _ := filepath.Glob(pat)
for _, d := range dirs {
if v, ok := readInt(filepath.Join(d, "temp1_input")); ok {
return float64(v) / 1000
}
}
}
return 0
}
// ---- network ----
// Virtual interfaces that only ever carry traffic already counted on a real one.
var skipIfacePrefix = []string{"veth", "br-", "docker", "virbr", "vnet", "tap", "lxc", "lxd", "cni", "flannel", "cali", "kube", "podman", "dummy"}
func skipIface(n string) bool {
if n == "lo" {
return true
}
for _, p := range skipIfacePrefix {
if strings.HasPrefix(n, p) {
return true
}
}
return false
}
func (c *Collector) net(dt float64) []Net {
out := []Net{}
f, err := os.Open("/proc/net/dev")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
name, rest, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
name = strings.TrimSpace(name)
if skipIface(name) {
continue
}
fs := strings.Fields(rest)
if len(fs) < 16 {
continue
}
rx, _ := strconv.ParseUint(fs[0], 10, 64)
tx, _ := strconv.ParseUint(fs[8], 10, 64)
n := Net{Iface: name, RxTotal: rx, TxTotal: tx}
st := strings.TrimSpace(readStr("/sys/class/net/" + name + "/operstate"))
n.Up = st == "up" || st == "unknown" // wireguard/tun report "unknown" while carrying traffic
if p, ok := c.prevNet[name]; ok && dt > 0 {
n.RxBps = round1(float64(rx-p[0]) / dt)
n.TxBps = round1(float64(tx-p[1]) / dt)
}
c.prevNet[name] = [2]uint64{rx, tx}
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool { return out[i].Iface < out[j].Iface })
return out
}
// ---- disks ----
// Pseudo block devices with nothing physical behind them.
var skipDiskPrefix = []string{"loop", "ram", "zram", "nbd", "sr", "fd", "dm-", "md", "drbd", "zd", "pmem"}
func skipDisk(n string) bool {
// eMMC boot/RPMB/general-purpose areas: regions of the chip, not drives
if strings.HasPrefix(n, "mmcblk") && (strings.Contains(n, "boot") || strings.HasSuffix(n, "rpmb") || strings.Contains(n, "gp")) {
return true
}
for _, p := range skipDiskPrefix {
if strings.HasPrefix(n, p) {
return true
}
}
return false
}
func diskModel(name string) string {
dev := "/sys/block/" + name + "/device/"
for _, f := range []string{"model", "name", "vendor"} { // SCSI/NVMe: model; MMC/SD: name
if s := strings.TrimSpace(readStr(dev + f)); s != "" {
return s
}
}
return ""
}
func (c *Collector) disks(dt float64) []Disk {
out := []Disk{}
// /sys/block lists whole devices only (partitions live underneath), so this is
// the authoritative "is a disk" test across sd/nvme/mmcblk/vd/xvd/hd/…
ents, err := os.ReadDir("/sys/block")
if err != nil {
return out
}
whole := map[string]bool{}
for _, e := range ents {
if !skipDisk(e.Name()) {
whole[e.Name()] = true
}
}
f, err := os.Open("/proc/diskstats")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 14 || !whole[fs[2]] {
continue
}
name := fs[2]
rd, _ := strconv.ParseUint(fs[5], 10, 64)
wr, _ := strconv.ParseUint(fs[9], 10, 64)
ticks, _ := strconv.ParseUint(fs[12], 10, 64)
d := Disk{Name: name, Model: diskModel(name), Temp: blockTemp(name)}
if sz, ok := readInt("/sys/block/" + name + "/size"); ok {
d.Size = uint64(sz) * sectorSize
}
if d.Size == 0 {
continue // empty card reader / detached media
}
if p, ok := c.prevDisk[name]; ok && dt > 0 {
d.ReadBps = round1(float64(rd-p[0]) * sectorSize / dt)
d.WriteBps = round1(float64(wr-p[1]) * sectorSize / dt)
d.UtilPct = round1(float64(ticks-p[2]) / (dt * 1000) * 100)
if d.UtilPct > 100 {
d.UtilPct = 100
}
}
c.prevDisk[name] = [3]uint64{rd, wr, ticks}
out = append(out, d)
}
out = foldNVMe(out)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
// foldNVMe merges nvme0n1, nvme0n2, … into the drive they are: one controller presenting
// several namespaces, which the kernel lists as whole disks apiece. An Apple SSD shows three —
// the 466 GiB of storage and two of firmware, a few MiB each — and a page that lists disks was
// listing all three as separate drives. The Disks page has always folded them (internal/storage
// does the same); this is what the dashboard, the Top tab and the disk.* sensors read.
//
// Rates add up across namespaces. Busy does not: they share one controller, so the time it
// spent working is the longest of them, never the sum.
func foldNVMe(devs []Disk) []Disk {
byCtrl := map[string]int{}
out := devs[:0]
for _, d := range devs {
m := nsRe.FindStringSubmatch(d.Name)
if m == nil {
out = append(out, d)
continue
}
ctrl := m[1]
if i, ok := byCtrl[ctrl]; ok {
out[i].Size += d.Size
out[i].ReadBps = round1(out[i].ReadBps + d.ReadBps)
out[i].WriteBps = round1(out[i].WriteBps + d.WriteBps)
if d.UtilPct > out[i].UtilPct {
out[i].UtilPct = d.UtilPct
}
if out[i].Temp == 0 {
out[i].Temp = d.Temp
}
continue
}
d.Name = ctrl
if mdl := strings.TrimSpace(readStr("/sys/class/nvme/" + ctrl + "/model")); mdl != "" {
d.Model = mdl
}
byCtrl[ctrl] = len(out)
out = append(out, d)
}
return out
}
// ---- filesystems ----
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
var pseudoFS = map[string]bool{
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
"tracefs": true, "configfs": true, "fusectl": true, "hugetlbfs": true, "mqueue": true, "efivarfs": true,
"binfmt_misc": true, "autofs": true, "rpc_pipefs": true, "nsfs": true, "overlay": true, "squashfs": true,
"fuse.portal": true, "fuse.gvfsd-fuse": true, "fuse.lxcfs": true, "selinuxfs": true, "none": true, "zfs_snap": true,
}
// Mount prefixes that are container/snap/runtime internals, not the host's storage.
var skipMountPrefix = []string{"/var/lib/docker/", "/var/lib/containers/", "/var/lib/kubelet/", "/run/", "/snap/", "/proc/", "/sys/", "/dev/"}
func skipMount(m string) bool {
for _, p := range skipMountPrefix {
if strings.HasPrefix(m, p) {
return true
}
}
return false
}
func filesystems() []FS {
out := []FS{}
b, err := os.ReadFile("/proc/mounts")
if err != nil {
return out
}
seen := map[string]bool{}
for _, line := range strings.Split(string(b), "\n") {
fs := strings.Fields(line)
if len(fs) < 3 || pseudoFS[fs[2]] {
continue
}
mount := strings.ReplaceAll(fs[1], `\040`, " ")
if seen[mount] || skipMount(mount) {
continue
}
seen[mount] = true
// statfs on a dead network mount can block; bound it.
done := make(chan FS, 1)
go func() {
var st syscall.Statfs_t
if err := syscall.Statfs(mount, &st); err != nil {
done <- FS{}
return
}
bs := uint64(st.Bsize)
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs}
}()
select {
case r := <-done:
if r.Size > 0 {
out = append(out, r)
}
case <-time.After(3 * time.Second):
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it
}
}
return out
}
// ---- UPS ----
func runOut(args ...string) (string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
b, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if err != nil {
return "", false
}
return string(b), true
}
// leadingNum parses the number at the start of "119 V", "47 min.", "170 Watt(17 %)".
func leadingNum(s string) float64 {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == '.') {
end++
}
v, _ := strconv.ParseFloat(s[:end], 64)
return v
}
// upsPwrstat: CyberPower PowerPanel. pwrstat only talks to its daemon as root, so fall
// back to passwordless sudo when plain execution is refused.
func upsPwrstat() *UPS {
out, ok := runOut("pwrstat", "-status")
if !ok {
if !priv.Available() {
return nil
}
if out, ok = runOut(priv.Wrap("pwrstat", "-status")...); !ok {
return nil
}
}
u := &UPS{Driver: "pwrstat"}
got := false
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, "....")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(strings.TrimLeft(v, "."))
got = true
switch k {
case "Model Name":
u.Model = v
case "State":
u.State = v
case "Power Supply by":
u.Source = v
u.OnBattery = !strings.Contains(v, "Utility")
case "Utility Voltage":
u.InV = leadingNum(v)
case "Output Voltage":
u.OutV = leadingNum(v)
case "Battery Capacity":
u.BatteryPct = leadingNum(v)
case "Remaining Runtime":
u.RuntimeMin = leadingNum(v)
case "Load":
u.LoadW = leadingNum(v)
if i := strings.Index(v, "("); i >= 0 {
u.LoadPct = leadingNum(v[i+1:])
}
case "Last Power Event":
u.LastEvent = v
}
}
if !got {
return nil
}
return u
}
// upsNUT: Network UPS Tools. `upsc -l` names the UPSes; `upsc <name>` dumps key: value.
func upsNUT() *UPS {
list, ok := runOut("upsc", "-l")
if !ok {
return nil
}
name := ""
for _, l := range strings.Split(list, "\n") {
if l = strings.TrimSpace(l); l != "" {
name = l
break
}
}
if name == "" {
return nil
}
out, ok := runOut("upsc", name)
if !ok {
return nil
}
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
if len(kv) == 0 {
return nil
}
u := &UPS{Driver: "nut", Model: strings.TrimSpace(kv["ups.mfr"] + " " + kv["ups.model"])}
status := kv["ups.status"]
u.OnBattery = strings.Contains(status, "OB")
u.State = status
u.Source = "Utility Power"
if u.OnBattery {
u.Source = "Battery"
}
u.InV = leadingNum(kv["input.voltage"])
u.OutV = leadingNum(kv["output.voltage"])
u.BatteryPct = leadingNum(kv["battery.charge"])
u.RuntimeMin = round1(leadingNum(kv["battery.runtime"]) / 60)
u.LoadPct = leadingNum(kv["ups.load"])
u.LoadW = leadingNum(kv["ups.realpower"])
if u.LoadW == 0 {
u.LoadW = round1(leadingNum(kv["ups.realpower.nominal"]) * u.LoadPct / 100)
}
return u
}
// upsAPC: apcupsd. `apcaccess` prints "KEY : value" lines.
func upsAPC() *UPS {
out, ok := runOut("apcaccess", "status")
if !ok {
return nil
}
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
if kv["STATUS"] == "" {
return nil
}
u := &UPS{Driver: "apcupsd", Model: kv["MODEL"], State: kv["STATUS"]}
u.OnBattery = strings.Contains(kv["STATUS"], "ONBATT")
u.Source = "Utility Power"
if u.OnBattery {
u.Source = "Battery"
}
u.InV = leadingNum(kv["LINEV"])
u.OutV = leadingNum(kv["OUTPUTV"])
u.BatteryPct = leadingNum(kv["BCHARGE"])
u.RuntimeMin = leadingNum(kv["TIMELEFT"])
u.LoadPct = leadingNum(kv["LOADPCT"])
u.LoadW = round1(leadingNum(kv["NOMPOWER"]) * u.LoadPct / 100)
if kv["LASTXFER"] != "" {
u.LastEvent = kv["LASTXFER"]
}
return u
}
// ---- processes (Processes tab; sampled only while someone is looking) ----
type procTimes struct {
ticks uint64
at time.Time
}
type procStatic struct {
user string
cmd string
}
var clkTck = float64(100) // Linux USER_HZ; sysconf(_SC_CLK_TCK) is 100 on every mainstream build
var uidNames = map[string]string{}
func uidName(uid string) string {
if n, ok := uidNames[uid]; ok {
return n
}
n := uid
if u, err := user.LookupId(uid); err == nil {
n = u.Username
}
uidNames[uid] = n
return n
}
// coreIDs maps logical CPUs to physical cores from sysfs topology: on a chip with
// hyper-threading, CPUs 8–15 are the second threads of cores 0–7, and coretemp names its
// sensors by core. Read once; the topology does not change while we run.
var (
coreIDsOnce sync.Once
coreIDsVal []int
)
func coreIDs(n int) []int {
coreIDsOnce.Do(func() {
for i := 0; ; i++ {
b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/core_id")
if err != nil {
break
}
id, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil {
break
}
coreIDsVal = append(coreIDsVal, id)
}
})
if len(coreIDsVal) < n {
return nil
}
return coreIDsVal[:n]
}
// ProcTable is the Processes tab payload.
type ProcTable struct {
T int64 `json:"t"`
CPU float64 `json:"cpu"`
Cores []float64 `json:"cores"`
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
CPUModel string `json:"cpu_model,omitempty"`
CPUMHz float64 `json:"cpu_mhz,omitempty"`
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
CPUTemp float64 `json:"cpu_temp,omitempty"`
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
Load [3]float64 `json:"load"`
Mem Mem `json:"mem"`
Swap Mem `json:"swap"`
Net []Net `json:"net"`
Uptime float64 `json:"uptime"`
Procs []Proc `json:"procs"`
Total int `json:"total"`
Running int `json:"running"`
}
var cpuModel = func() string {
for _, line := range strings.Split(readStr("/proc/cpuinfo"), "\n") {
if strings.HasPrefix(line, "model name") || strings.HasPrefix(line, "Model") || strings.HasPrefix(line, "Hardware") {
if _, v, ok := strings.Cut(line, ":"); ok {
return strings.Join(strings.Fields(v), " ")
}
}
}
// ARM boards don't put a model name in cpuinfo; the device tree names the machine.
for _, p := range []string{"/sys/firmware/devicetree/base/model", "/proc/device-tree/model", "/sys/devices/virtual/dmi/id/product_name"} {
if m := strings.TrimRight(strings.TrimSpace(readStr(p)), "\x00"); m != "" {
return m
}
}
return ""
}()
var cpuMaxMHz = func() float64 {
if v, ok := readInt("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"); ok {
return float64(v) / 1000
}
return 0
}()
func cpuMHz() float64 {
var sum float64
n := 0
for _, line := range strings.Split(readStr("/proc/cpuinfo"), "\n") {
if strings.HasPrefix(line, "cpu MHz") {
if _, v, ok := strings.Cut(line, ":"); ok {
f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64)
sum += f
n++
}
}
}
if n == 0 { // ARM etc.: scaling_cur_freq in kHz
if v, ok := readInt("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"); ok {
return float64(v) / 1000
}
return 0
}
return sum / float64(n)
}
// cpuTemp picks the sensor that best represents the CPU package.
func cpuTemp(temps []Temp) float64 {
best, max := 0.0, 0.0
for _, t := range temps {
l := strings.ToLower(t.Label)
if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") ||
t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" {
return t.C
}
if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max {
max, best = t.C, t.C
}
}
return best
}
var bootTime = func() int64 {
for _, line := range strings.Split(readStr("/proc/stat"), "\n") {
if strings.HasPrefix(line, "btime ") {
v, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "btime ")), 10, 64)
return v
}
}
return 0
}()
// Processes walks /proc and returns every process with CPU% since the previous call.
// The first call after a quiet spell reports 0% for everyone; the UI polls, so the
// next one is right. Static per-pid facts (user, cmdline) are cached.
func (c *Collector) Processes() ProcTable {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if c.prevProc == nil {
c.prevProc = map[int]procTimes{}
c.procInfo = map[int]procStatic{}
}
if now.Sub(c.procAt) < 100*time.Millisecond && c.lastTable != nil { // coalesce simultaneous clients only
return *c.lastTable
}
ents, err := os.ReadDir("/proc")
if err != nil {
return ProcTable{Procs: []Proc{}}
}
cur := map[int]procTimes{}
seen := map[int]bool{}
pageSize := uint64(os.Getpagesize())
procs := make([]Proc, 0, len(ents))
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil || !e.IsDir() {
continue
}
stat := readStr("/proc/" + e.Name() + "/stat")
close := strings.LastIndex(stat, ")")
open := strings.Index(stat, "(")
if open < 0 || close < 0 {
continue
}
name := stat[open+1 : close]
f := strings.Fields(stat[close+2:])
if len(f) < 22 {
continue
}
utime, _ := strconv.ParseUint(f[11], 10, 64)
stime, _ := strconv.ParseUint(f[12], 10, 64)
threads, _ := strconv.Atoi(f[17])
startTicks, _ := strconv.ParseUint(f[19], 10, 64)
rssPages, _ := strconv.ParseUint(f[21], 10, 64)
ppid, _ := strconv.Atoi(f[1])
nice, _ := strconv.Atoi(f[16])
ticks := utime + stime
cur[pid] = procTimes{ticks: ticks, at: now}
seen[pid] = true
p := Proc{PID: pid, PPID: ppid, Name: name, State: f[0], Threads: threads, Nice: nice, RSS: rssPages * pageSize,
CPUTime: float64(ticks) / clkTck, Started: (bootTime + int64(float64(startTicks)/clkTck)) * 1000}
if prev, ok := c.prevProc[pid]; ok && prev.ticks <= ticks {
if dt := now.Sub(prev.at).Seconds(); dt > 0 {
p.CPU = round1(float64(ticks-prev.ticks) / clkTck / dt * 100)
}
}
if p.State == "Z" {
p.RSS = 0
}
info, ok := c.procInfo[pid]
if !ok {
for _, line := range strings.Split(readStr("/proc/"+e.Name()+"/status"), "\n") {
if strings.HasPrefix(line, "Uid:") {
if fs := strings.Fields(line); len(fs) > 1 {
info.user = uidName(fs[1])
}
break
}
}
if cmd := strings.ReplaceAll(strings.TrimRight(readStr("/proc/"+e.Name()+"/cmdline"), "\x00"), "\x00", " "); cmd != "" {
if len(cmd) > 300 {
cmd = cmd[:300] + "…"
}
info.cmd = cmd
}
c.procInfo[pid] = info
}
p.User, p.Cmd = info.user, info.cmd
procs = append(procs, p)
}
for pid := range c.procInfo {
if !seen[pid] {
delete(c.procInfo, pid)
}
}
c.prevProc, c.procAt = cur, now
sort.Slice(procs, func(i, j int) bool {
if procs[i].CPU != procs[j].CPU {
return procs[i].CPU > procs[j].CPU
}
return procs[i].RSS > procs[j].RSS
})
t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg(),
CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUMaxMHz: cpuMaxMHz, CPUHist: []float64{}, Net: []Net{}}
for _, p := range procs {
if p.State == "R" {
t.Running++
}
}
t.Mem, t.Swap = meminfo()
if n := len(c.ring); n > 0 {
last := c.ring[n-1]
t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps)
t.CoreIDs = coreIDs(len(last.Cores))
from := n - 90
if from < 0 {
from = 0
}
for _, s := range c.ring[from:] {
t.CPUHist = append(t.CPUHist, s.CPU)
}
}
c.lastTable = &t
return t
}
// Signal sends a signal to a pid; falls back to sudo when the process isn't ours.
func Signal(pid int, sig syscall.Signal) error {
if pid <= 1 || pid == os.Getpid() {
return errors.New("refusing to signal pid " + strconv.Itoa(pid))
}
if strings.TrimRight(readStr("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00") == "" {
if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil {
return errors.New("refusing to signal a kernel thread")
}
}
if err := syscall.Kill(pid, sig); err == nil {
return nil
} else if !errors.Is(err, syscall.EPERM) {
return err
}
if priv.Available() {
k := priv.Wrap("kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid))
if err := exec.Command(k[0], k[1:]...).Run(); err == nil {
return nil
}
}
return errors.New("permission denied")
}
// ---- small helpers ----
func readStr(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(b)
}
func readInt(p string) (int64, bool) {
s := strings.TrimSpace(readStr(p))
if s == "" {
return 0, false
}
v, err := strconv.ParseInt(s, 10, 64)
return v, err == nil
}
func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
func lookPath(bin string) bool { _, err := exec.LookPath(bin); return err == nil }