From e4df878ac0b04ed7e364a9749699a94700309cc8 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 06:01:12 -0700 Subject: [PATCH] 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 --- internal/api/server.go | 5 ++ internal/metrics/metrics.go | 173 ++++++++++++++++++++++-------------- web/src/Dashboard.tsx | 34 ------- web/src/Procs.tsx | 114 ++++++++++++++++++++++++ web/src/Shell.tsx | 7 +- web/src/api.ts | 6 +- web/src/styles.css | 29 ++++-- 7 files changed, 257 insertions(+), 111 deletions(-) create mode 100644 web/src/Procs.tsx diff --git a/internal/api/server.go b/internal/api/server.go index e3713e1..c920f7c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub)) 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("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) 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 ---- +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) { pid, err := strconv.Atoi(r.PathValue("pid")) if err != nil { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index e20ac1c..fabeb0e 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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. diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index f16891d..a4c8d22 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -2,7 +2,6 @@ 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)' @@ -21,7 +20,6 @@ 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) @@ -110,36 +108,6 @@ 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 @@ -262,8 +230,6 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode { k} /> ) - case 'procs': - return case 'ups': return ( <> diff --git a/web/src/Procs.tsx b/web/src/Procs.tsx new file mode 100644 index 0000000..03b5712 --- /dev/null +++ b/web/src/Procs.tsx @@ -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 = { 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 ( +
+
= 0.9 ? 'bad' : frac >= 0.7 ? 'warn' : ''))} style={{ width: `${Math.min(100, frac * 100)}%` }} />
+ {label} +
+ ) +} + +export default function Procs() { + const [t, setT] = useState(null) + const [err, setErr] = useState('') + const [sort, setSort] = useState('cpu') + const [asc, setAsc] = useState(false) + const [q, setQ] = useState('') + const [mine, setMine] = useState(false) + const [sel, setSel] = useState(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 = '') => ( + + ) + + if (!t) return
{err ?
{err}
: }
+ 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 ( +
+
+
+ {t.cores.map((c, i) => )} +
+
+ + {t.swap.total > 0 && } + load {t.load.map((l) => l.toFixed(2)).join(' ')} · up {fmtUptime(t.uptime)} · {t.total} tasks · cpu {pct(t.cpu)} +
+
+ setQ(e.target.value)} /> + + + {rows.length} +
+
+ +
+
+ {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')} +
+ {rows.map((p) => ( +
setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}> + {p.pid} + {p.user} + {p.name}{p.threads > 1 && ×{p.threads}} + = 50 ? ' hot' : '')}>{p.cpu.toFixed(1)} + {fmtBytes(p.rss)} + {fmtDur(p.cpu_time)} + {fmtStart(p.started)} +
+ ))} +
+ + {selected && ( +
+
{selected.name}pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads
+
{selected.cmd || '(kernel thread)'}
+
+ signal(selected, 'TERM')}>Terminate + signal(selected, 'KILL')}>Kill −9 + cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()} +
+
+ )} +
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 49937cd..b33a989 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -9,6 +9,7 @@ import Cron from './Cron' import Updates from './Updates' import Disks from './Disks' import Files from './Files' +import Procs from './Procs' import Power from './Power' import { Btn, Toasts, toast } from './ui' @@ -21,6 +22,7 @@ const FolderIcon = ( const GROUPS: Group[] = [ { id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, + { id: 'procs', label: 'Processes', icon: '≡', page: true }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', 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 }, ] -type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' +type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' function fmtUptime(s: number): string { 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: () =>
{view === 'dashboard' && } + {view === 'procs' && } {view === 'services' && } {view === 'cron' && } {view === 'updates' && } @@ -141,7 +144,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/api.ts b/web/src/api.ts index c8c96a5..33dc2ad 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 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 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 { 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; procs: Proc[]; uptime: number + temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number } export interface Series { 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}` export const api = { + procs: () => req('GET', '/api/procs'), 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)}`), diff --git a/web/src/styles.css b/web/src/styles.css index 65a3a15..01e5cec 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -288,10 +288,25 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .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; } +/* processes tab */ +.procs-page { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); } +.procs-top { padding: 8px 10px; background: var(--bg2); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; } +.cores { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 4px 10px; } +.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; } +.memrow { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .memrow .pbar { flex: 1; min-width: 180px; } .memrow .pbar .num { min-width: 0; } +.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)); } +}