diff --git a/internal/api/server.go b/internal/api/server.go index e0fd397..e3713e1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -16,6 +16,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" "github.com/coder/websocket" @@ -125,6 +126,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots)) m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub)) m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot)) + m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal)) m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) @@ -922,6 +924,30 @@ func (s *Server) zfsSnapshot(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// ---- processes ---- + +func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) { + pid, err := strconv.Atoi(r.PathValue("pid")) + if err != nil { + jsonErr(w, 400, "bad pid") + return + } + var body struct { + Signal string `json:"signal"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body) + sig := syscall.SIGTERM + if body.Signal == "KILL" { + sig = syscall.SIGKILL + } + if err := metrics.Signal(pid, sig); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("procs: sent %s to pid %d (from %s)", sig, pid, clientIP(r)) + w.WriteHeader(204) +} + // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index c2b72b4..e20ac1c 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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 { diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index a4c8d22..f16891d 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, toast } from './ui' +import { useState as useStateP } from 'react' const MAX = 300 const S1 = 'var(--s1)', S2 = 'var(--s2)' @@ -20,6 +21,7 @@ const MODULES: Module[] = [ { id: 'disks', label: 'Disks', kind: 'card' }, { id: 'fs', label: 'Filesystems', kind: 'card' }, { id: 'ups', label: 'UPS', kind: 'card' }, + { id: 'procs', label: 'Processes', kind: 'card' }, ] const byId = (id: string) => MODULES.find((m) => m.id === id) @@ -108,6 +110,36 @@ function Tile({ label, value, sub, children }: { label: string; value: string; s return <>
{label}
{value}
{sub &&
{sub}
}{children} } +// Processes card keeps its own sort state, so it's a component rather than a plain body. +function Procs({ s, edit }: { s: Sample; edit: Edit }) { + const [by, setBy] = useStateP<'cpu' | 'rss'>('cpu') + const rows = [...s.procs].filter((p) => !edit.hiddenItems.has(`proc:${p.name}`)).sort((a, b) => (by === 'cpu' ? b.cpu - a.cpu : b.rss - a.rss)).slice(0, 12) + const kill = async (p: Sample['procs'][number]) => { + if (!window.confirm(`Send SIGTERM to ${p.name} (pid ${p.pid})?`)) return + try { await api.procSignal(p.pid, 'TERM'); toast(`SIGTERM sent to ${p.name}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( + <> +

Processes

+ + +
+
+ {rows.map((p) => ( +
+ {p.name}{p.pid} · {p.user}{p.threads > 1 ? ` · ${p.threads} thr` : ''} + {p.cpu.toFixed(1)}% + {fmtBytes(p.rss)} + {edit.on ? : } +
+ ))} + {rows.length === 0 &&
No process data yet.
} +
+ k} /> + + ) +} + function body(id: string, { s, h, edit }: Ctx): ReactNode { const shown = (key: string) => !edit.hiddenItems.has(key) const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0 @@ -230,6 +262,8 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode { k} /> ) + case 'procs': + return case 'ups': return ( <> diff --git a/web/src/api.ts b/web/src/api.ts index 9335050..c8c96a5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -40,10 +40,11 @@ export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: num export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } export interface FS { mount: string; type: string; size: number; used: number } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } +export interface Proc { pid: number; name: string; cmd?: string; user: string; cpu: number; rss: number; threads: number; state: string } export interface Sample { t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number] mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number } - temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number + temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; procs: Proc[]; uptime: number } export interface Series { t: number[]; cpu: number[]; mem: number[] @@ -82,6 +83,7 @@ export interface FileInfo extends FileEntry { path: string; text: boolean } const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}` export const api = { + procSignal: (pid: number, signal: 'TERM' | 'KILL') => req('POST', `/api/procs/${pid}/signal`, { signal }), files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`), fileStat: (path: string, sudo: boolean) => req('GET', `/api/files/stat?${q(path, sudo)}`), fileRead: async (path: string, sudo: boolean): Promise => { diff --git a/web/src/styles.css b/web/src/styles.css index ceacc74..65a3a15 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -287,3 +287,11 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .path-input { width: 100%; margin: 4px 0 0; } .files-tools .filter { width: 80px; } } + +/* processes */ +.procs { display: flex; flex-direction: column; } +.proc { display: grid; grid-template-columns: 1fr 64px 72px 24px; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.proc:last-child { border-bottom: 0; } +.pname { display: flex; flex-direction: column; min-width: 0; } .pname span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } +.pname small { color: var(--muted); font-size: 11px; } +.proc .num { text-align: right; }