Processes tab (btop-style): every process with CPU%, memory, cpu time, start, sortable + filter, per-core and memory bars, detail pane with terminate/kill; module removed from the dashboard; sampling only while the tab is open
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ff922bda74
commit
e4df878ac0
+107
-66
@@ -99,13 +99,17 @@ type UPS struct {
|
||||
|
||||
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
|
||||
RSS uint64 `json:"rss"` // bytes
|
||||
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 {
|
||||
@@ -122,7 +126,6 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -142,18 +145,19 @@ type Collector struct {
|
||||
interval 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
|
||||
procAt time.Time
|
||||
lastProc []Proc
|
||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||
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
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
@@ -288,11 +292,6 @@ 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
|
||||
@@ -857,13 +856,18 @@ func upsAPC() *UPS {
|
||||
return u
|
||||
}
|
||||
|
||||
// ---- processes ----
|
||||
// ---- 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{}
|
||||
|
||||
@@ -879,25 +883,57 @@ func uidName(uid string) string {
|
||||
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{}
|
||||
// ProcTable is the Processes tab payload.
|
||||
type ProcTable struct {
|
||||
T int64 `json:"t"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
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) < 500*time.Millisecond && c.lastTable != nil { // coalesce bursts of clients
|
||||
return *c.lastTable
|
||||
}
|
||||
ents, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return ProcTable{Procs: []Proc{}}
|
||||
}
|
||||
cur := map[int]procTimes{}
|
||||
var all []Proc
|
||||
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")
|
||||
// "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 {
|
||||
@@ -911,58 +947,63 @@ func (c *Collector) procs(now time.Time) []Proc {
|
||||
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}
|
||||
p := Proc{PID: pid, Name: name, State: f[0], Threads: threads, RSS: rssPages * pageSize}
|
||||
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 {
|
||||
dt := now.Sub(prev.at).Seconds()
|
||||
if dt > 0 {
|
||||
if dt := now.Sub(prev.at).Seconds(); 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])
|
||||
if p.State == "Z" {
|
||||
p.RSS = 0
|
||||
}
|
||||
}
|
||||
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])
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if cmd := strings.ReplaceAll(strings.TrimRight(readStr("/proc/"+pidS+"/cmdline"), "\x00"), "\x00", " "); cmd != "" {
|
||||
if len(cmd) > 160 {
|
||||
cmd = cmd[:160] + "…"
|
||||
}
|
||||
out[i].Cmd = cmd
|
||||
p.User, p.Cmd = info.user, info.cmd
|
||||
procs = append(procs, p)
|
||||
}
|
||||
for pid := range c.procInfo {
|
||||
if !seen[pid] {
|
||||
delete(c.procInfo, pid)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []Proc{}
|
||||
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()}
|
||||
t.Mem, t.Swap = meminfo()
|
||||
if n := len(c.ring); n > 0 {
|
||||
t.CPU, t.Cores = c.ring[n-1].CPU, c.ring[n-1].Cores
|
||||
}
|
||||
return out
|
||||
c.lastTable = &t
|
||||
return t
|
||||
}
|
||||
|
||||
// Signal sends a signal to a pid; falls back to sudo when the process isn't ours.
|
||||
|
||||
Reference in New Issue
Block a user