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
@@ -126,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("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/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||||
|
m.HandleFunc("GET /api/procs", s.auth(s.procList))
|
||||||
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
||||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||||
@@ -926,6 +927,10 @@ func (s *Server) zfsSnapshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ---- processes ----
|
// ---- processes ----
|
||||||
|
|
||||||
|
func (s *Server) procList(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
jsonOK(w, 200, s.metrics.Processes())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
pid, err := strconv.Atoi(r.PathValue("pid"))
|
pid, err := strconv.Atoi(r.PathValue("pid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+107
-66
@@ -99,13 +99,17 @@ type UPS struct {
|
|||||||
|
|
||||||
type Proc struct {
|
type Proc struct {
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
|
PPID int `json:"ppid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Cmd string `json:"cmd,omitempty"`
|
Cmd string `json:"cmd,omitempty"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
CPU float64 `json:"cpu"` // percent of one core
|
CPU float64 `json:"cpu"` // percent of one core
|
||||||
RSS uint64 `json:"rss"` // bytes
|
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||||
|
RSS uint64 `json:"rss"` // bytes
|
||||||
Threads int `json:"threads"`
|
Threads int `json:"threads"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
Nice int `json:"nice"`
|
||||||
|
Started int64 `json:"started"` // unix ms
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sample struct {
|
type Sample struct {
|
||||||
@@ -122,7 +126,6 @@ type Sample struct {
|
|||||||
Disks []Disk `json:"disks"`
|
Disks []Disk `json:"disks"`
|
||||||
FS []FS `json:"fs"`
|
FS []FS `json:"fs"`
|
||||||
UPS *UPS `json:"ups"`
|
UPS *UPS `json:"ups"`
|
||||||
Procs []Proc `json:"procs"` // top by CPU and by RSS, merged
|
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,18 +145,19 @@ type Collector struct {
|
|||||||
interval time.Duration
|
interval time.Duration
|
||||||
wake chan struct{} // nudges Run when the interval changes
|
wake chan struct{} // nudges Run when the interval changes
|
||||||
|
|
||||||
prevCPU []cpuTimes
|
prevCPU []cpuTimes
|
||||||
prevNet map[string][2]uint64
|
prevNet map[string][2]uint64
|
||||||
prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms
|
prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms
|
||||||
prevT time.Time
|
prevT time.Time
|
||||||
lastFS []FS
|
lastFS []FS
|
||||||
fsAt time.Time
|
fsAt time.Time
|
||||||
lastUPS *UPS
|
lastUPS *UPS
|
||||||
upsAt time.Time
|
upsAt time.Time
|
||||||
prevProc map[int]procTimes
|
prevProc map[int]procTimes
|
||||||
procAt time.Time
|
procInfo map[int]procStatic
|
||||||
lastProc []Proc
|
procAt time.Time
|
||||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
lastTable *ProcTable
|
||||||
|
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollector() *Collector {
|
func NewCollector() *Collector {
|
||||||
@@ -288,11 +292,6 @@ func (c *Collector) sample() {
|
|||||||
c.lastUPS, c.upsAt = c.upsProbe(), now
|
c.lastUPS, c.upsAt = c.upsProbe(), now
|
||||||
}
|
}
|
||||||
s.UPS = c.lastUPS
|
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 {
|
if first {
|
||||||
return // deltas are meaningless on the priming pass
|
return // deltas are meaningless on the priming pass
|
||||||
@@ -857,13 +856,18 @@ func upsAPC() *UPS {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- processes ----
|
// ---- processes (Processes tab; sampled only while someone is looking) ----
|
||||||
|
|
||||||
type procTimes struct {
|
type procTimes struct {
|
||||||
ticks uint64
|
ticks uint64
|
||||||
at time.Time
|
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 clkTck = float64(100) // Linux USER_HZ; sysconf(_SC_CLK_TCK) is 100 on every mainstream build
|
||||||
var uidNames = map[string]string{}
|
var uidNames = map[string]string{}
|
||||||
|
|
||||||
@@ -879,25 +883,57 @@ func uidName(uid string) string {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// procs returns the union of the top 15 by CPU and top 15 by RSS.
|
// ProcTable is the Processes tab payload.
|
||||||
func (c *Collector) procs(now time.Time) []Proc {
|
type ProcTable struct {
|
||||||
ents, err := os.ReadDir("/proc")
|
T int64 `json:"t"`
|
||||||
if err != nil {
|
CPU float64 `json:"cpu"`
|
||||||
return []Proc{}
|
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 {
|
if c.prevProc == nil {
|
||||||
c.prevProc = map[int]procTimes{}
|
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{}
|
cur := map[int]procTimes{}
|
||||||
var all []Proc
|
seen := map[int]bool{}
|
||||||
pageSize := uint64(os.Getpagesize())
|
pageSize := uint64(os.Getpagesize())
|
||||||
|
procs := make([]Proc, 0, len(ents))
|
||||||
for _, e := range ents {
|
for _, e := range ents {
|
||||||
pid, err := strconv.Atoi(e.Name())
|
pid, err := strconv.Atoi(e.Name())
|
||||||
if err != nil || !e.IsDir() {
|
if err != nil || !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stat := readStr("/proc/" + e.Name() + "/stat")
|
stat := readStr("/proc/" + e.Name() + "/stat")
|
||||||
// "pid (comm with spaces) state ppid ..." — comm can contain spaces and parens
|
|
||||||
close := strings.LastIndex(stat, ")")
|
close := strings.LastIndex(stat, ")")
|
||||||
open := strings.Index(stat, "(")
|
open := strings.Index(stat, "(")
|
||||||
if open < 0 || close < 0 {
|
if open < 0 || close < 0 {
|
||||||
@@ -911,58 +947,63 @@ func (c *Collector) procs(now time.Time) []Proc {
|
|||||||
utime, _ := strconv.ParseUint(f[11], 10, 64)
|
utime, _ := strconv.ParseUint(f[11], 10, 64)
|
||||||
stime, _ := strconv.ParseUint(f[12], 10, 64)
|
stime, _ := strconv.ParseUint(f[12], 10, 64)
|
||||||
threads, _ := strconv.Atoi(f[17])
|
threads, _ := strconv.Atoi(f[17])
|
||||||
|
startTicks, _ := strconv.ParseUint(f[19], 10, 64)
|
||||||
rssPages, _ := strconv.ParseUint(f[21], 10, 64)
|
rssPages, _ := strconv.ParseUint(f[21], 10, 64)
|
||||||
|
ppid, _ := strconv.Atoi(f[1])
|
||||||
|
nice, _ := strconv.Atoi(f[16])
|
||||||
ticks := utime + stime
|
ticks := utime + stime
|
||||||
cur[pid] = procTimes{ticks: ticks, at: now}
|
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 {
|
if prev, ok := c.prevProc[pid]; ok && prev.ticks <= ticks {
|
||||||
dt := now.Sub(prev.at).Seconds()
|
if dt := now.Sub(prev.at).Seconds(); dt > 0 {
|
||||||
if dt > 0 {
|
|
||||||
p.CPU = round1(float64(ticks-prev.ticks) / clkTck / dt * 100)
|
p.CPU = round1(float64(ticks-prev.ticks) / clkTck / dt * 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all = append(all, p)
|
if p.State == "Z" {
|
||||||
}
|
p.RSS = 0
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
}
|
info, ok := c.procInfo[pid]
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].RSS > all[j].RSS })
|
if !ok {
|
||||||
for i := 0; i < len(all) && i < 15; i++ {
|
for _, line := range strings.Split(readStr("/proc/"+e.Name()+"/status"), "\n") {
|
||||||
if !pick[all[i].PID] {
|
if strings.HasPrefix(line, "Uid:") {
|
||||||
pick[all[i].PID] = true
|
if fs := strings.Fields(line); len(fs) > 1 {
|
||||||
out = append(out, all[i])
|
info.user = uidName(fs[1])
|
||||||
}
|
}
|
||||||
}
|
break
|
||||||
// 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/"+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 != "" {
|
p.User, p.Cmd = info.user, info.cmd
|
||||||
if len(cmd) > 160 {
|
procs = append(procs, p)
|
||||||
cmd = cmd[:160] + "…"
|
}
|
||||||
}
|
for pid := range c.procInfo {
|
||||||
out[i].Cmd = cmd
|
if !seen[pid] {
|
||||||
|
delete(c.procInfo, pid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if out == nil {
|
c.prevProc, c.procAt = cur, now
|
||||||
out = []Proc{}
|
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.
|
// Signal sends a signal to a pid; falls back to sudo when the process isn't ours.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
import { Loading, Spinner, toast } from './ui'
|
import { Loading, Spinner, toast } from './ui'
|
||||||
import { useState as useStateP } from 'react'
|
|
||||||
|
|
||||||
const MAX = 300
|
const MAX = 300
|
||||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
||||||
@@ -21,7 +20,6 @@ const MODULES: Module[] = [
|
|||||||
{ id: 'disks', label: 'Disks', kind: 'card' },
|
{ id: 'disks', label: 'Disks', kind: 'card' },
|
||||||
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
||||||
{ id: 'ups', label: 'UPS', kind: 'card' },
|
{ id: 'ups', label: 'UPS', kind: 'card' },
|
||||||
{ id: 'procs', label: 'Processes', kind: 'card' },
|
|
||||||
]
|
]
|
||||||
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
||||||
|
|
||||||
@@ -110,36 +108,6 @@ function Tile({ label, value, sub, children }: { label: string; value: string; s
|
|||||||
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{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 (
|
|
||||||
<>
|
|
||||||
<div className="sub-head"><h2 style={{ margin: 0 }}>Processes</h2><span className="spacer" />
|
|
||||||
<button className={'chip' + (by === 'cpu' ? ' on' : '')} onClick={() => setBy('cpu')}>CPU</button>
|
|
||||||
<button className={'chip' + (by === 'rss' ? ' on' : '')} onClick={() => setBy('rss')}>Memory</button>
|
|
||||||
</div>
|
|
||||||
<div className="procs">
|
|
||||||
{rows.map((p) => (
|
|
||||||
<div className="proc" key={p.pid} title={p.cmd || p.name}>
|
|
||||||
<span className="pname"><span>{p.name}</span><small>{p.pid} · {p.user}{p.threads > 1 ? ` · ${p.threads} thr` : ''}</small></span>
|
|
||||||
<span className="num">{p.cpu.toFixed(1)}%</span>
|
|
||||||
<span className="num muted">{fmtBytes(p.rss)}</span>
|
|
||||||
{edit.on ? <HideBtn k={`proc:${p.name}`} edit={edit} /> : <button className="hide-item" title="send SIGTERM" onClick={() => kill(p)}>✕</button>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rows.length === 0 && <div className="empty">No process data yet.</div>}
|
|
||||||
</div>
|
|
||||||
<HiddenChips prefix="proc" edit={edit} label={(k) => k} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
||||||
const shown = (key: string) => !edit.hiddenItems.has(key)
|
const shown = (key: string) => !edit.hiddenItems.has(key)
|
||||||
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
||||||
@@ -262,8 +230,6 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|||||||
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
case 'procs':
|
|
||||||
return <Procs s={s} edit={edit} />
|
|
||||||
case 'ups':
|
case 'ups':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, Proc, ProcTable } from './api'
|
||||||
|
import { fmtBytes } from './Dashboard'
|
||||||
|
import { Btn, Loading, toast } from './ui'
|
||||||
|
|
||||||
|
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
|
||||||
|
const pct = (v: number) => `${Math.round(v)}%`
|
||||||
|
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||||
|
const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}` : `${s.toFixed(1)}s`)
|
||||||
|
const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) }
|
||||||
|
const STATE: Record<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' }
|
||||||
|
|
||||||
|
function Bar({ frac, label, cls }: { frac: number; label: string; cls?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="pbar" title={label}>
|
||||||
|
<div className="meter"><div className={'fill ' + (cls ?? (frac >= 0.9 ? 'bad' : frac >= 0.7 ? 'warn' : ''))} style={{ width: `${Math.min(100, frac * 100)}%` }} /></div>
|
||||||
|
<span className="num">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Procs() {
|
||||||
|
const [t, setT] = useState<ProcTable | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [sort, setSort] = useState<Col>('cpu')
|
||||||
|
const [asc, setAsc] = useState(false)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [mine, setMine] = useState(false)
|
||||||
|
const [sel, setSel] = useState<number | null>(null)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const id = setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, 2000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [load, paused])
|
||||||
|
|
||||||
|
const head = (c: Col, label: string, cls = '') => (
|
||||||
|
<button className={'th ' + cls + (sort === c ? ' on' : '')} onClick={() => { if (sort === c) setAsc(!asc); else { setSort(c); setAsc(c === 'name' || c === 'user' || c === 'pid') } }}>
|
||||||
|
{label}{sort === c ? (asc ? ' ▲' : ' ▼') : ''}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!t) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading processes…" />}</div>
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = t.procs
|
||||||
|
.filter((p) => (!needle || p.name.toLowerCase().includes(needle) || (p.cmd ?? '').toLowerCase().includes(needle) || p.user.toLowerCase().includes(needle) || String(p.pid) === needle) && (!mine || p.user !== 'root'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const va = a[sort] as number | string, vb = b[sort] as number | string
|
||||||
|
const r = typeof va === 'string' ? (va as string).localeCompare(vb as string) : (va as number) - (vb as number)
|
||||||
|
return asc ? r : -r
|
||||||
|
})
|
||||||
|
const selected = sel !== null ? t.procs.find((p) => p.pid === sel) : undefined
|
||||||
|
const memFrac = t.mem.total ? t.mem.used / t.mem.total : 0
|
||||||
|
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
|
||||||
|
|
||||||
|
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
||||||
|
if (!window.confirm(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
||||||
|
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="procs-page">
|
||||||
|
<div className="procs-top">
|
||||||
|
<div className="cores">
|
||||||
|
{t.cores.map((c, i) => <Bar key={i} frac={c / 100} label={`${i}: ${pct(c)}`} />)}
|
||||||
|
</div>
|
||||||
|
<div className="memrow">
|
||||||
|
<Bar frac={memFrac} label={`mem ${fmtBytes(t.mem.used)} / ${fmtBytes(t.mem.total)}`} />
|
||||||
|
{t.swap.total > 0 && <Bar frac={swapFrac} label={`swap ${fmtBytes(t.swap.used)} / ${fmtBytes(t.swap.total)}`} />}
|
||||||
|
<span className="num muted">load {t.load.map((l) => l.toFixed(2)).join(' ')} · up {fmtUptime(t.uptime)} · {t.total} tasks · cpu {pct(t.cpu)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="procs-tools">
|
||||||
|
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
|
||||||
|
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
|
||||||
|
<span className="muted">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ptable">
|
||||||
|
<div className="prow phead">
|
||||||
|
{head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Name')}{head('cpu', 'CPU%', 'num')}{head('rss', 'MEM', 'num')}{head('cpu_time', 'Time', 'num')}{head('started', 'Started', 'num')}
|
||||||
|
</div>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
|
||||||
|
<span className="num muted">{p.pid}</span>
|
||||||
|
<span className="ell">{p.user}</span>
|
||||||
|
<span className="ell mono">{p.name}{p.threads > 1 && <small className="muted"> ×{p.threads}</small>}</span>
|
||||||
|
<span className={'num' + (p.cpu >= 50 ? ' hot' : '')}>{p.cpu.toFixed(1)}</span>
|
||||||
|
<span className="num">{fmtBytes(p.rss)}</span>
|
||||||
|
<span className="num muted">{fmtDur(p.cpu_time)}</span>
|
||||||
|
<span className="num muted">{fmtStart(p.started)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="pdetail">
|
||||||
|
<div className="sub-head"><b className="mono">{selected.name}</b><span className="muted">pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads</span><span className="spacer" /><button className="icon-btn" onClick={() => setSel(null)}>✕</button></div>
|
||||||
|
<div className="mono cmdline">{selected.cmd || '(kernel thread)'}</div>
|
||||||
|
<div className="svc-actions">
|
||||||
|
<Btn className="btn" onClick={() => signal(selected, 'TERM')}>Terminate</Btn>
|
||||||
|
<Btn className="btn danger" onClick={() => signal(selected, 'KILL')}>Kill −9</Btn>
|
||||||
|
<span className="muted">cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
-2
@@ -9,6 +9,7 @@ import Cron from './Cron'
|
|||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
|
import Procs from './Procs'
|
||||||
import Power from './Power'
|
import Power from './Power'
|
||||||
import { Btn, Toasts, toast } from './ui'
|
import { Btn, Toasts, toast } from './ui'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const FolderIcon = (
|
|||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||||
|
{ id: 'procs', label: 'Processes', icon: '≡', page: true },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||||
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
||||||
@@ -29,7 +31,7 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files'
|
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs'
|
||||||
|
|
||||||
function fmtUptime(s: number): string {
|
function fmtUptime(s: number): string {
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||||
@@ -114,6 +116,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
{view === 'dashboard' && <Dashboard />}
|
{view === 'dashboard' && <Dashboard />}
|
||||||
|
{view === 'procs' && <Procs />}
|
||||||
{view === 'services' && <Services />}
|
{view === 'services' && <Services />}
|
||||||
{view === 'cron' && <Cron />}
|
{view === 'cron' && <Cron />}
|
||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
@@ -141,7 +144,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-2
@@ -40,11 +40,12 @@ 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 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 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 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 Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||||
|
export interface ProcTable { t: number; cpu: number; cores: number[]; load: [number, number, number]; mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }; uptime: number; procs: Proc[]; total: number }
|
||||||
export interface Sample {
|
export interface Sample {
|
||||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
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 }
|
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; procs: Proc[]; uptime: number
|
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number
|
||||||
}
|
}
|
||||||
export interface Series {
|
export interface Series {
|
||||||
t: number[]; cpu: number[]; mem: number[]
|
t: number[]; cpu: number[]; mem: number[]
|
||||||
@@ -83,6 +84,7 @@ export interface FileInfo extends FileEntry { path: string; text: boolean }
|
|||||||
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
procs: () => req<ProcTable>('GET', '/api/procs'),
|
||||||
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
|
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
|
||||||
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
|
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
|
||||||
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
||||||
|
|||||||
+22
-7
@@ -288,10 +288,25 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.files-tools .filter { width: 80px; }
|
.files-tools .filter { width: 80px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* processes */
|
/* processes tab */
|
||||||
.procs { display: flex; flex-direction: column; }
|
.procs-page { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||||
.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; }
|
.procs-top { padding: 8px 10px; background: var(--bg2); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; }
|
||||||
.proc:last-child { border-bottom: 0; }
|
.cores { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 4px 10px; }
|
||||||
.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; }
|
.pbar { display: flex; align-items: center; gap: 6px; font-size: 11px; } .pbar .meter { flex: 1; } .pbar .num { color: var(--muted); white-space: nowrap; min-width: 52px; }
|
||||||
.pname small { color: var(--muted); font-size: 11px; }
|
.memrow { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .memrow .pbar { flex: 1; min-width: 180px; } .memrow .pbar .num { min-width: 0; }
|
||||||
.proc .num { text-align: right; }
|
.procs-tools { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.procs-tools input { flex: 1; min-width: 0; padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 13px; }
|
||||||
|
.ptable { flex: 1; overflow: auto; font-size: 13px; }
|
||||||
|
.prow { display: grid; grid-template-columns: 60px 80px minmax(120px, 1fr) 60px 76px 64px 64px; gap: 8px; align-items: center; padding: 4px 10px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||||
|
.prow:hover { background: var(--bg3); } .prow.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.prow.zombie { color: var(--muted); font-style: italic; } .prow.running .mono { color: var(--ok); }
|
||||||
|
.prow .num { text-align: right; font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; } .prow .hot { color: var(--s2); }
|
||||||
|
.prow .ell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.phead { position: sticky; top: 0; background: var(--bg2); z-index: 1; cursor: default; padding: 0 10px; }
|
||||||
|
.th { padding: 6px 0; text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .th.num { text-align: right; } .th.on { color: var(--fg); }
|
||||||
|
.pdetail { border-top: 1px solid var(--border); background: var(--bg2); padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.cmdline { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: var(--fg); background: #0d1117; padding: 8px; border-radius: 6px; border: 1px solid var(--border); max-height: 120px; overflow: auto; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.prow { grid-template-columns: 52px minmax(100px, 1fr) 56px 70px; } .prow > :nth-child(2), .prow > :nth-child(6), .prow > :nth-child(7) { display: none; }
|
||||||
|
.cores { grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user