▍ humdrum codex / soft

Merge system-panel-syswatch: richer System page metrics

ed30070945e7c409aebeecf5a8794dbd62bee850
humdrum <me@humdrum.me> · 2026-07-08 17:29

parent 000636bd

parent 1cabe798

Merge system-panel-syswatch: richer System page metrics

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: