Dashboard: Processes module (top by CPU/memory, PID, user, threads, SIGTERM), sampled at most every 2 s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 05:58:11 -07:00
co-authored by Claude Fable 5.1
parent 4986af5ac9
commit ff922bda74
5 changed files with 219 additions and 1 deletions
+148
View File
@@ -12,8 +12,10 @@ package metrics
import (
"bufio"
"context"
"errors"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"sort"
@@ -95,6 +97,17 @@ type UPS struct {
LastEvent string `json:"last_event,omitempty"`
}
type Proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Cmd string `json:"cmd,omitempty"`
User string `json:"user"`
CPU float64 `json:"cpu"` // percent of one core
RSS uint64 `json:"rss"` // bytes
Threads int `json:"threads"`
State string `json:"state"`
}
type Sample struct {
T int64 `json:"t"` // unix ms
Interval int64 `json:"interval_ms"` // sampling interval in effect
@@ -109,6 +122,7 @@ type Sample struct {
Disks []Disk `json:"disks"`
FS []FS `json:"fs"`
UPS *UPS `json:"ups"`
Procs []Proc `json:"procs"` // top by CPU and by RSS, merged
Uptime float64 `json:"uptime"`
}
@@ -136,6 +150,9 @@ type Collector struct {
fsAt time.Time
lastUPS *UPS
upsAt time.Time
prevProc map[int]procTimes
procAt time.Time
lastProc []Proc
upsProbe func() *UPS // nil when no UPS daemon is installed
}
@@ -271,6 +288,11 @@ func (c *Collector) sample() {
c.lastUPS, c.upsAt = c.upsProbe(), now
}
s.UPS = c.lastUPS
// Processes: walking /proc is the expensive part, so at most every 2 s regardless of interval.
if c.lastProc == nil || now.Sub(c.procAt) >= 2*time.Second {
c.lastProc, c.procAt = c.procs(now), now
}
s.Procs = c.lastProc
if first {
return // deltas are meaningless on the priming pass
@@ -835,6 +857,132 @@ func upsAPC() *UPS {
return u
}
// ---- processes ----
type procTimes struct {
ticks uint64
at time.Time
}
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
}
// procs returns the union of the top 15 by CPU and top 15 by RSS.
func (c *Collector) procs(now time.Time) []Proc {
ents, err := os.ReadDir("/proc")
if err != nil {
return []Proc{}
}
if c.prevProc == nil {
c.prevProc = map[int]procTimes{}
}
cur := map[int]procTimes{}
var all []Proc
pageSize := uint64(os.Getpagesize())
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil || !e.IsDir() {
continue
}
stat := readStr("/proc/" + e.Name() + "/stat")
// "pid (comm with spaces) state ppid ..." — comm can contain spaces and parens
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])
rssPages, _ := strconv.ParseUint(f[21], 10, 64)
ticks := utime + stime
cur[pid] = procTimes{ticks: ticks, at: now}
p := Proc{PID: pid, Name: name, State: f[0], Threads: threads, RSS: rssPages * pageSize}
if prev, ok := c.prevProc[pid]; ok && prev.ticks <= ticks {
dt := now.Sub(prev.at).Seconds()
if dt > 0 {
p.CPU = round1(float64(ticks-prev.ticks) / clkTck / dt * 100)
}
}
all = append(all, p)
}
c.prevProc = cur
// pick: top by CPU, then fill with top by RSS
sort.Slice(all, func(i, j int) bool { return all[i].CPU > all[j].CPU })
pick := map[int]bool{}
var out []Proc
for i := 0; i < len(all) && i < 15; i++ {
if all[i].CPU > 0 || i < 5 {
pick[all[i].PID] = true
out = append(out, all[i])
}
}
sort.Slice(all, func(i, j int) bool { return all[i].RSS > all[j].RSS })
for i := 0; i < len(all) && i < 15; i++ {
if !pick[all[i].PID] {
pick[all[i].PID] = true
out = append(out, all[i])
}
}
// enrich only the chosen ones (user, cmdline) — cheap because it's ≤30 reads
for i := range out {
pidS := strconv.Itoa(out[i].PID)
for _, line := range strings.Split(readStr("/proc/"+pidS+"/status"), "\n") {
if strings.HasPrefix(line, "Uid:") {
if fs := strings.Fields(line); len(fs) > 1 {
out[i].User = uidName(fs[1])
}
break
}
}
if cmd := strings.ReplaceAll(strings.TrimRight(readStr("/proc/"+pidS+"/cmdline"), "\x00"), "\x00", " "); cmd != "" {
if len(cmd) > 160 {
cmd = cmd[:160] + "…"
}
out[i].Cmd = cmd
}
}
if out == nil {
out = []Proc{}
}
return out
}
// 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 {
return errors.New("refusing to signal pid " + strconv.Itoa(pid))
}
if err := syscall.Kill(pid, sig); err == nil {
return nil
} else if !errors.Is(err, syscall.EPERM) {
return err
}
if lookPath("sudo") {
if err := exec.Command("sudo", "-n", "kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid)).Run(); err == nil {
return nil
}
}
return errors.New("permission denied")
}
// ---- small helpers ----
func readStr(p string) string {