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:
co-authored by
Claude Fable 5.1
parent
4986af5ac9
commit
ff922bda74
@@ -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 <><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 {
|
||||
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 {
|
||||
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
||||
</>
|
||||
)
|
||||
case 'procs':
|
||||
return <Procs s={s} edit={edit} />
|
||||
case 'ups':
|
||||
return (
|
||||
<>
|
||||
|
||||
+3
-1
@@ -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<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)}`),
|
||||
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
||||
fileRead: async (path: string, sudo: boolean): Promise<string> => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user