TUI System page: per-core bars, swap, disk I/O, proc/thread + top-mem
1cabe7986a8b95ca866b123b2159d2261d5c3b18
humdrum <me@humdrum.me> · 2026-07-08 17:29
parent 000636bd
TUI System page: per-core bars, swap, disk I/O, proc/thread + top-mem Fill the System page with more psutil metrics (syswatch-inspired, no new deps or sudo): - Per-core CPU bars in a new Cores panel beside the CPU-history sparkline - Swap usage in the Memory panel - Disk read/write throughput in the Network panel (retitled Network + Disk I/O) - Process / thread / running / sleeping counts in the CPU panel - A second Top-by-Memory process table alongside Top-by-CPU Temps/fans/GPU skipped (psutil can't reach them on macOS; would need a helper). TASK-038 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 files changed
- → System-page-per-core-bars-swap-disk-I-O-proc-thread-top-mem.md +23 −0
@@ -0,0 +1,23 @@
+---
+id: TASK-038
+title: 'System page: per-core bars, swap, disk I/O, proc/thread + top-mem'
+status: To Do
+assignee: []
+created_date: '2026-07-09 00:29'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 38000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Fill the System page with more psutil metrics (syswatch-inspired, no new deps/sudo): per-core CPU bars (new Cores panel beside CPU-history sparkline), swap in Memory panel, disk read/write throughput in the Network panel (retitled Network + Disk I/O), process/thread/running/sleeping counts in CPU panel, and a second Top-by-Memory process table. Temps/fans/GPU skipped (need a helper).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Per-core bars, swap, disk I/O, proc/thread counts, top-mem table all render
+<!-- AC:END -->
tui/dashboard.py +116 −25
@@ -586,43 +586,76 @@ """Collects local host metrics. sample() is blocking — call off the UI thread."""
def __init__(self) -> None:
psutil.cpu_percent(interval=None) # prime; next call returns a real delta
+ psutil.cpu_percent(interval=None, percpu=True) # prime per-core too
self.cpu_history: deque[float] = deque([0.0] * 60, maxlen=60)
self._last_net = psutil.net_io_counters()
self._last_net_t = time.monotonic()
+ self._last_disk = psutil.disk_io_counters()
def sample(self) -> dict:
cpu = psutil.cpu_percent(interval=None)
self.cpu_history.append(cpu)
+ percpu = psutil.cpu_percent(interval=None, percpu=True)
net = psutil.net_io_counters()
now = time.monotonic()
dt = now - self._last_net_t
- down = up = None
+ down = up = disk_read = disk_write = None
if dt > 0.5:
down = max(0.0, (net.bytes_recv - self._last_net.bytes_recv) / dt)
up = max(0.0, (net.bytes_sent - self._last_net.bytes_sent) / dt)
self._last_net, self._last_net_t = net, now
- procs = []
- for pr in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]):
+ disk_io = psutil.disk_io_counters()
+ if dt > 0.5 and disk_io and self._last_disk:
+ disk_read = max(0.0, (disk_io.read_bytes - self._last_disk.read_bytes) / dt)
+ disk_write = max(0.0, (disk_io.write_bytes - self._last_disk.write_bytes) / dt)
+ self._last_disk = disk_io
+
+ all_procs = []
+ nproc = nthreads = running = sleeping = 0
+ for pr in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info", "num_threads", "status"]):
info = pr.info
- if info.get("cpu_percent") is None:
- continue
- procs.append(info)
- procs.sort(key=lambda i: i["cpu_percent"], reverse=True)
+ nproc += 1
+ nthreads += info.get("num_threads") or 0
+ st = info.get("status")
+ if st == "running":
+ running += 1
+ elif st == "sleeping":
+ sleeping += 1
+ all_procs.append(info)
+ by_cpu = sorted(
+ (p for p in all_procs if p.get("cpu_percent") is not None),
+ key=lambda i: i["cpu_percent"],
+ reverse=True,
+ )[:12]
+ by_mem = sorted(
+ (p for p in all_procs if p.get("memory_info")),
+ key=lambda i: i["memory_info"].rss,
+ reverse=True,
+ )[:12]
return {
"cpu": cpu,
+ "percpu": percpu,
"load": psutil.getloadavg(),
"cores": psutil.cpu_count() or 0,
"mem": psutil.virtual_memory(),
+ "swap": psutil.swap_memory(),
"disk": psutil.disk_usage("/"),
"down": down,
"up": up,
+ "disk_read": disk_read,
+ "disk_write": disk_write,
"battery": psutil.sensors_battery(),
"uptime": time.time() - psutil.boot_time(),
"idle": _idle_seconds(),
- "procs": procs[:12],
+ "nproc": nproc,
+ "nthreads": nthreads,
+ "running": running,
+ "sleeping": sleeping,
+ "procs": by_cpu,
+ "procs_mem": by_mem,
"history": list(self.cpu_history),
}
@@ -634,15 +667,44 @@ t.append(" %\n")
l1, l5, l15 = s["load"]
t.append(f"Load {l1:.2f} · {l5:.2f} · {l15:.2f}\n", style="dim")
t.append(f"{s['cores']} cores\n", style="dim")
+ t.append("\n")
+ t.append(f"{s['nproc']} procs · {s['nthreads']} thr\n", style="dim")
+ t.append(f"{s['running']} run · {s['sleeping']} sleep\n", style="dim")
+ return t
+
+
+# Bar glyphs from empty→full, for the per-core meters.
+_CORE_BARS = "▁▂▃▄▅▆▇█"
+
+
+def render_cores(s: dict) -> Text:
+ percpu = s.get("percpu") or []
+ if not percpu:
+ return Text("—", style="dim italic")
+ t = Text()
+ for i, pct in enumerate(percpu):
+ idx = min(len(_CORE_BARS) - 1, int(pct / 100 * len(_CORE_BARS)))
+ style = "green" if pct < 50 else ("yellow" if pct < 85 else "red")
+ t.append(f"{i} ", style="dim")
+ t.append(_CORE_BARS[idx], style=style)
+ t.append(f" {pct:>3.0f}% ", style="dim")
+ if i % 2 == 1:
+ t.append("\n")
+ if len(percpu) % 2 == 1:
+ t.append("\n")
return t
def render_memdisk(s: dict) -> Text:
- mem, disk = s["mem"], s["disk"]
+ mem, disk, swap = s["mem"], s["disk"], s["swap"]
t = Text()
t.append(f"{mem.percent:.0f}", style="bold yellow")
t.append(" % mem\n")
t.append(f"{_gb(mem.used):.1f} / {_gb(mem.total):.0f} GB\n", style="dim")
+ if swap.total > 0:
+ t.append(f"{swap.percent:.0f}", style="bold")
+ t.append(" % swap\n")
+ t.append(f"{_gb(swap.used):.1f} / {_gb(swap.total):.1f} GB\n", style="dim")
t.append("\n")
t.append(f"{_gb(disk.free):.0f} GB", style="bold")
t.append(" disk free\n")
@@ -652,10 +714,16 @@
def render_net(s: dict) -> Text:
t = Text()
- t.append("↓ ", style="green")
+ t.append("Net\n", style="dim")
+ t.append(" ↓ ", style="green")
t.append(f"{_rate(s['down'])}\n", style="bold")
- t.append("↑ ", style="blue")
+ t.append(" ↑ ", style="blue")
t.append(f"{_rate(s['up'])}\n", style="bold")
+ t.append("Disk\n", style="dim")
+ t.append(" R ", style="green")
+ t.append(f"{_rate(s.get('disk_read'))}\n", style="bold")
+ t.append(" W ", style="blue")
+ t.append(f"{_rate(s.get('disk_write'))}\n", style="bold")
return t
@@ -1002,8 +1070,9 @@ #brief-0, #brief-1, #brief-2, #brief-3 { width: 1fr; }
#sys-cpu, #sys-memdisk, #sys-net, #sys-power { width: 1fr; }
- .sys-spark-row { height: 4; }
+ .sys-spark-row { height: 8; }
#sys-sparkline {
+ width: 1.6fr;
height: 100%;
margin: 0 0;
padding: 0 1;
@@ -1011,8 +1080,9 @@ border: round $panel;
border-title-color: $primary;
color: $accent;
}
- #sys-procs {
- width: 100%;
+ #sys-cores { width: 1fr; }
+ #sys-procs, #sys-procs-mem {
+ width: 1fr;
height: 100%;
border: round $panel;
border-title-color: $primary;
@@ -1040,9 +1110,13 @@ self._sys = SystemStats()
self._sys_timer = self.set_interval(
SYS_REFRESH_SECS, self.refresh_system, pause=True
)
- table = self._q("#sys-procs", DataTable)
- table.add_columns("PID", "Name", "CPU %", "Mem")
- table.cursor_type = "none"
+ for tid, cols in (
+ ("#sys-procs", ("PID", "Name", "CPU %", "Mem")),
+ ("#sys-procs-mem", ("PID", "Name", "Mem", "CPU %")),
+ ):
+ tbl = self._q(tid, DataTable)
+ tbl.add_columns(*cols)
+ tbl.cursor_type = "none"
self.refresh_data()
self.set_interval(REFRESH_SECS, self.refresh_data)
@@ -1083,16 +1157,20 @@ with Vertical(id="page-system"):
with Horizontal(classes="row"):
yield DashPanel("CPU", "sys-cpu")
yield DashPanel("Memory · Disk", "sys-memdisk")
- yield DashPanel("Network", "sys-net")
+ yield DashPanel("Network · Disk I/O", "sys-net")
yield DashPanel("Power · Uptime", "sys-power")
with Horizontal(classes="sys-spark-row"):
spark = Sparkline([], id="sys-sparkline", summary_function=max)
spark.border_title = "CPU history"
yield spark
+ yield DashPanel("Cores", "sys-cores")
with Horizontal(classes="row row-tall"):
procs = DataTable(id="sys-procs")
- procs.border_title = "Processes"
+ procs.border_title = "Top by CPU"
yield procs
+ procs_mem = DataTable(id="sys-procs-mem")
+ procs_mem.border_title = "Top by Memory"
+ yield procs_mem
yield CommandBar(id="cmd-bar")
yield Footer()
@@ -1234,19 +1312,32 @@ panel("sys-cpu").set_content(render_cpu(s))
panel("sys-memdisk").set_content(render_memdisk(s))
panel("sys-net").set_content(render_net(s))
panel("sys-power").set_content(render_power(s))
+ panel("sys-cores").set_content(render_cores(s))
self._q("#sys-sparkline", Sparkline).data = s["history"]
- table = self._q("#sys-procs", DataTable)
- table.clear(columns=False)
+ def mem_lbl(pr: dict) -> str:
+ mem = pr.get("memory_info")
+ return f"{mem.rss / 2**20:.0f} MB" if mem else "—"
+
+ cpu_table = self._q("#sys-procs", DataTable)
+ cpu_table.clear(columns=False)
for pr in s["procs"]:
- mem = pr.get("memory_info")
- mem_lbl = f"{mem.rss / 2**20:.0f} MB" if mem else "—"
- table.add_row(
+ cpu_table.add_row(
str(pr["pid"]),
(pr.get("name") or "?")[:40],
f"{pr['cpu_percent']:.1f}",
- mem_lbl,
+ mem_lbl(pr),
+ )
+
+ mem_table = self._q("#sys-procs-mem", DataTable)
+ mem_table.clear(columns=False)
+ for pr in s["procs_mem"]:
+ mem_table.add_row(
+ str(pr["pid"]),
+ (pr.get("name") or "?")[:40],
+ mem_lbl(pr),
+ f"{pr.get('cpu_percent') or 0:.1f}",
)
def _update_subtitle(self) -> None: