From b802dcf313568b02e39245cad6cc9883afa0d1ad Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 06:05:09 -0700 Subject: [PATCH] Processes tab: btop-style cpu/mem/net boxes with labels, gradient bars, block graph, CPU model/GHz/temp, memory breakdown, per-interface rates, and a refresh interval control Co-Authored-By: Claude Fable 5.1 --- internal/metrics/metrics.go | 101 +++++++++++++++++++++++++++++------ web/src/Procs.tsx | 103 +++++++++++++++++++++++++++--------- web/src/api.ts | 3 +- web/src/styles.css | 41 ++++++++++---- 4 files changed, 197 insertions(+), 51 deletions(-) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index fabeb0e..80a9af3 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -37,9 +37,12 @@ const ( ) type Mem struct { - Total uint64 `json:"total"` - Used uint64 `json:"used"` - Avail uint64 `json:"avail"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + Avail uint64 `json:"avail"` + Free uint64 `json:"free,omitempty"` + Cached uint64 `json:"cached,omitempty"` + Buffers uint64 `json:"buffers,omitempty"` } type Temp struct { @@ -379,7 +382,7 @@ func meminfo() (Mem, Mem) { n, _ := strconv.ParseUint(f[0], 10, 64) m[k] = n * 1024 } - mem := Mem{Total: m["MemTotal"], Avail: m["MemAvailable"]} + mem := Mem{Total: m["MemTotal"], Avail: m["MemAvailable"], Free: m["MemFree"], Buffers: m["Buffers"], Cached: m["Cached"] + m["SReclaimable"]} if mem.Avail == 0 { // pre-3.14 kernels: approximate mem.Avail = m["MemFree"] + m["Buffers"] + m["Cached"] } @@ -885,15 +888,69 @@ func uidName(uid string) string { // 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"` + T int64 `json:"t"` + CPU float64 `json:"cpu"` + Cores []float64 `json:"cores"` + CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first + CPUModel string `json:"cpu_model,omitempty"` + CPUMHz float64 `json:"cpu_mhz,omitempty"` + CPUTemp float64 `json:"cpu_temp,omitempty"` + Load [3]float64 `json:"load"` + Mem Mem `json:"mem"` + Swap Mem `json:"swap"` + Net []Net `json:"net"` + Uptime float64 `json:"uptime"` + Procs []Proc `json:"procs"` + Total int `json:"total"` + Running int `json:"running"` +} + +var cpuModel = func() string { + for _, line := range strings.Split(readStr("/proc/cpuinfo"), "\n") { + if strings.HasPrefix(line, "model name") || strings.HasPrefix(line, "Model") || strings.HasPrefix(line, "Hardware") { + if _, v, ok := strings.Cut(line, ":"); ok { + return strings.Join(strings.Fields(v), " ") + } + } + } + return "" +}() + +func cpuMHz() float64 { + var sum float64 + n := 0 + for _, line := range strings.Split(readStr("/proc/cpuinfo"), "\n") { + if strings.HasPrefix(line, "cpu MHz") { + if _, v, ok := strings.Cut(line, ":"); ok { + f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) + sum += f + n++ + } + } + } + if n == 0 { // ARM etc.: scaling_cur_freq in kHz + if v, ok := readInt("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"); ok { + return float64(v) / 1000 + } + return 0 + } + return sum / float64(n) +} + +// cpuTemp picks the sensor that best represents the CPU package. +func cpuTemp(temps []Temp) float64 { + best, max := 0.0, 0.0 + for _, t := range temps { + l := strings.ToLower(t.Label) + if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") || + t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" { + return t.C + } + if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max { + max, best = t.C, t.C + } + } + return best } var bootTime = func() int64 { @@ -997,10 +1054,24 @@ func (c *Collector) Processes() ProcTable { } return procs[i].RSS > procs[j].RSS }) - t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg()} + t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg(), + CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUHist: []float64{}, Net: []Net{}} + for _, p := range procs { + if p.State == "R" { + t.Running++ + } + } 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 + last := c.ring[n-1] + t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps) + from := n - 90 + if from < 0 { + from = 0 + } + for _, s := range c.ring[from:] { + t.CPUHist = append(t.CPUHist, s.CPU) + } } c.lastTable = &t return t diff --git a/web/src/Procs.tsx b/web/src/Procs.tsx index 03b5712..d35ec15 100644 --- a/web/src/Procs.tsx +++ b/web/src/Procs.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { api, Proc, ProcTable } from './api' -import { fmtBytes } from './Dashboard' +import { fmtBytes, fmtRate } from './Dashboard' import { Btn, Loading, toast } from './ui' type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time' @@ -10,14 +10,32 @@ const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Mat 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 }) { +const INTERVALS = [500, 1000, 2000, 5000] as const +const BLOCKS = '▁▂▃▄▅▆▇█' + +// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover +// from the right hides the unfilled part, so colour depends on position not on level. +function GBar({ frac }: { frac: number }) { + return
+} +function Row({ label, frac, value }: { label: string; frac: number; value: string }) { + return
{label}{value}
+} +// Block-character graph, coloured per column by its value, like btop's cpu graph. +function Graph({ values, max = 100 }: { values: number[]; max?: number }) { + const cols = 60 + const v = values.slice(-cols) + const pad = cols - v.length return ( -
-
= 0.9 ? 'bad' : frac >= 0.7 ? 'warn' : ''))} style={{ width: `${Math.min(100, frac * 100)}%` }} />
- {label} + ) } +function Box({ title, right, children, className = '' }: { title: string; right?: string; children: React.ReactNode; className?: string }) { + return
{title}{right && {right}}{children}
+} export default function Procs() { const [t, setT] = useState(null) @@ -28,18 +46,20 @@ export default function Procs() { const [mine, setMine] = useState(false) const [sel, setSel] = useState(null) const [paused, setPaused] = useState(false) + const [interval, setIntervalMs] = useState(() => Number(localStorage.getItem('hamlab.procs.interval')) || 2000) 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 id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval) + localStorage.setItem('hamlab.procs.interval', String(interval)) + return () => window.clearInterval(id) + }, [load, paused, interval]) const head = (c: Col, label: string, cls = '') => ( - ) @@ -65,25 +85,58 @@ export default function Procs() { 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} -
+ +
+ +
CPU{pct(t.cpu)}total, all cores
+
+
+ {t.cores.map((c, i) => )} +
+
+ load avg {t.load[0].toFixed(2)} {t.load[1].toFixed(2)} {t.load[2].toFixed(2)} (1 · 5 · 15 min, {t.cores.length} cores) + up {fmtUptime(t.uptime)} + {t.total} tasks, {t.running} running +
+
+ + + + + + {t.swap.total > 0 ? ( + <>
swap · {fmtBytes(t.swap.total)} total
+ + + ) :
no swap
} +
Used = total − available. Cached is file cache the kernel gives back under pressure, so "available" is the honest headroom.
+
+ + {t.net.filter((n) => n.up).map((n) => ( +
+ {n.iface} + ▼ {fmtRate(n.rx_bps)} ▲ {fmtRate(n.tx_bps)} + {fmtBytes(n.rx_total)} in · {fmtBytes(n.tx_total)} out +
+ ))} + {t.net.filter((n) => n.up).length === 0 &&
no interfaces up
} +
+
+
+ setQ(e.target.value)} /> + + + + {rows.length} shown
- {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')} + {head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Program')}{head('cpu', 'CPU %', 'num')}{head('rss', 'Memory', 'num')}{head('cpu_time', 'CPU time', 'num')}{head('started', 'Started', 'num')}
{rows.map((p) => (
setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}> diff --git a/web/src/api.ts b/web/src/api.ts index 33dc2ad..aa5088a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -41,7 +41,8 @@ export interface Disk { name: string; model?: string; size: number; read_bps: nu 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; 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 MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number } +export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_temp?: number; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; uptime: number; procs: Proc[]; total: number; running: 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 } diff --git a/web/src/styles.css b/web/src/styles.css index 01e5cec..000ab8d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -288,16 +288,34 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .files-tools .filter { width: 80px; } } -/* processes tab */ +/* processes tab — btop-style boxes */ .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; } +.procs-top { padding: 14px 10px 8px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } +.procs-top .netbox { grid-column: 1 / -1; } +.box { position: relative; border: 1px solid var(--border); border-radius: 8px; padding: 12px 12px 8px; min-width: 0; } +.box-title { position: absolute; top: -8px; left: 10px; padding: 0 6px; background: var(--bg); color: var(--accent2); font-weight: 700; font-size: 12px; letter-spacing: .5px; } +.box-right { position: absolute; top: -8px; right: 10px; padding: 0 6px; background: var(--bg); color: var(--muted); font-size: 11px; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.box-sub { margin: 8px 0 2px; color: var(--accent2); font-size: 11px; } +.box-foot { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; } .box-foot b { color: var(--fg); font-weight: 600; } +.small { font-size: 11px; } +.brow { display: grid; grid-template-columns: 64px 1fr auto; gap: 8px; align-items: center; padding: 2px 0; } +.bl { color: var(--muted); } .bv { font-variant-numeric: tabular-nums; white-space: nowrap; } .bv.big { font-size: 20px; font-weight: 700; color: var(--fg); } +.gbar { position: relative; height: 10px; border-radius: 3px; overflow: hidden; background: linear-gradient(90deg, #3fb950 0%, #9ccc3f 40%, #d29922 65%, #f0883e 82%, #ff7b72 100%); } +.gcover { position: absolute; top: 0; right: 0; bottom: 0; background: var(--bg3); } +.graph { display: flex; line-height: 1; font-size: 22px; letter-spacing: -1px; overflow: hidden; white-space: nowrap; } +.graph span { flex: 1 1 0; min-width: 0; text-align: center; } .graph .gpad { flex: 0 0 auto; color: var(--bg3); } +.graph .g1 { color: var(--ok); } .graph .g2 { color: var(--s2); } .graph .g3 { color: var(--bad); } +.cpu-total { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: end; margin-bottom: 8px; } +.cpu-total-v { display: flex; flex-direction: column; align-items: flex-end; } +.cores { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0 16px; } +.cores .brow { grid-template-columns: 30px 1fr 42px; } .cores .bv { text-align: right; } +.netrow { display: grid; grid-template-columns: 90px auto 1fr; gap: 12px; align-items: center; padding: 2px 0; } +.netrow .dn { color: var(--accent2); } .netrow .upc { color: var(--s2); } +.procs-tools { display: flex; gap: 6px; align-items: center; padding: 0 10px 8px; flex-wrap: wrap; } +.procs-tools input { flex: 1; min-width: 140px; padding: 6px 8px; background: var(--bg2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 13px; } +.procs-tools label { display: inline-flex; align-items: center; gap: 4px; } .procs-tools select.btn { padding: 3px 6px; } +.ptable { flex: 1; overflow: auto; font-size: 13px; border-top: 1px solid var(--border); } +.prow { display: grid; grid-template-columns: 60px 80px minmax(120px, 1fr) 60px 76px 70px 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); } @@ -307,6 +325,9 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .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) { + .procs-top { grid-template-columns: 1fr; gap: 14px; padding-top: 16px; } .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)); } + .cores { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .graph { font-size: 16px; } + .netrow { grid-template-columns: 70px 1fr; } .netrow > :nth-child(3) { display: none; } }