▍ humdrum codex / soft

TUI System: disk IOPS+latency and gateway/net latency ping

39346b10b39f705e97d966616b996ad6cd3b566b
humdrum <me@humdrum.me> · 2026-07-08 18:00

parent 850296b3

TUI System: disk IOPS+latency and gateway/net latency ping

netwatch/diskwatch-inspired additions (psutil + macOS route/ping, no new deps):
- Disk I/O panel now shows read/write IOPS and avg per-op latency (ms)
- Network panel shows gateway + internet (1.1.1.1) round-trip latency,
  colored green/yellow/red

TASK-039

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

2 files changed

- → System-disk-IOPSlatency-and-gateway-net-latency-ping.md +24 −0
@@ -0,0 +1,24 @@
+---
+id: TASK-039
+title: 'System: disk IOPS+latency and gateway/net latency ping'
+status: To Do
+assignee: []
+created_date: '2026-07-09 01:00'
+labels:
+  - feature
+dependencies: []
+priority: low
+ordinal: 39000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+netwatch/diskwatch-inspired: add read/write IOPS + avg per-op latency to the Disk I/O panel, and a gateway + internet (1.1.1.1) latency ping line to the Network panel. psutil + macOS route/ping, no new deps or sudo. Skipped: SMART/temp, per-process net, connection states (need root/tools).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Disk R/W IOPS + avg latency shown
+- [ ] #2 Gateway + net latency ping shown, colored by latency
+<!-- AC:END -->
tui/dashboard.py +72 −2
@@ -556,6 +556,42 @@ def _gb(n: float) -> float:
     return n / 2**30
 
 
+def _iops(n: float | None) -> str:
+    return "—" if n is None else f"{n:.0f}/s"
+
+
+def _ms(n: float | None) -> str:
+    return "—" if n is None else f"{n:.1f}ms"
+
+
+def _ping(host: str) -> float | None:
+    """Round-trip ms to host via one ping, or None if unreachable/slow."""
+    try:
+        out = subprocess.run(
+            ["ping", "-c", "1", "-t", "1", host],
+            capture_output=True, text=True, timeout=2,
+        ).stdout
+        if m := re.search(r"time=([\d.]+)", out):
+            return float(m.group(1))
+    except Exception:
+        pass
+    return None
+
+
+def _default_gateway() -> str | None:
+    """The LAN default-gateway IP (macOS `route`), or None."""
+    try:
+        out = subprocess.run(
+            ["route", "-n", "get", "default"],
+            capture_output=True, text=True, timeout=1,
+        ).stdout
+        if m := re.search(r"gateway:\s*([\d.]+)", out):
+            return m.group(1)
+    except Exception:
+        pass
+    return None
+
+
 def _idle_seconds() -> float | None:
     """Seconds since last keyboard/mouse input, via IOKit's HIDIdleTime."""
     try:
@@ -580,6 +616,7 @@         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()
+        self._gateway = _default_gateway()
 
     def sample(self) -> dict:
         cpu = psutil.cpu_percent(interval=None)
@@ -596,10 +633,22 @@             up = max(0.0, (net.bytes_sent - self._last_net.bytes_sent) / dt)
         self._last_net, self._last_net_t = net, now
 
         disk_io = psutil.disk_io_counters()
+        disk_rops = disk_wops = disk_rlat = disk_wlat = None
         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)
+            ld = self._last_disk
+            disk_read = max(0.0, (disk_io.read_bytes - ld.read_bytes) / dt)
+            disk_write = max(0.0, (disk_io.write_bytes - ld.write_bytes) / dt)
+            drc = disk_io.read_count - ld.read_count
+            dwc = disk_io.write_count - ld.write_count
+            disk_rops = max(0.0, drc / dt)
+            disk_wops = max(0.0, dwc / dt)
+            # Avg per-op latency = time spent / ops over the interval.
+            disk_rlat = (disk_io.read_time - ld.read_time) / drc if drc > 0 else 0.0
+            disk_wlat = (disk_io.write_time - ld.write_time) / dwc if dwc > 0 else 0.0
         self._last_disk = disk_io
+
+        lat_gw = _ping(self._gateway) if self._gateway else None
+        lat_net = _ping("1.1.1.1")
 
         all_procs = []
         nproc = nthreads = running = sleeping = 0
@@ -634,8 +683,14 @@             "swap": psutil.swap_memory(),
             "disk": psutil.disk_usage("/"),
             "down": down,
             "up": up,
+            "lat_gw": lat_gw,
+            "lat_net": lat_net,
             "disk_read": disk_read,
             "disk_write": disk_write,
+            "disk_rops": disk_rops,
+            "disk_wops": disk_wops,
+            "disk_rlat": disk_rlat,
+            "disk_wlat": disk_wlat,
             "battery": psutil.sensors_battery(),
             "uptime": time.time() - psutil.boot_time(),
             "idle": _idle_seconds(),
@@ -701,6 +756,13 @@     t.append(f"{_gb(disk.used):.0f} GB used · {disk.percent:.0f}%\n", style="dim")
     return t
 
 
+def _lat(ms: float | None) -> tuple[str, str]:
+    if ms is None:
+        return "—", "dim"
+    style = "green" if ms < 50 else ("yellow" if ms < 150 else "red")
+    return f"{ms:.0f}ms", style
+
+
 def render_net(s: dict) -> Text:
     t = Text()
     t.append("Net\n", style="dim")
@@ -708,11 +770,19 @@     t.append("  ↓ ", style="green")
     t.append(f"{_rate(s['down'])}\n", style="bold")
     t.append("  ↑ ", style="blue")
     t.append(f"{_rate(s['up'])}\n", style="bold")
+    gl, gs = _lat(s.get("lat_gw"))
+    nl, ns = _lat(s.get("lat_net"))
+    t.append("  gw ", style="dim")
+    t.append(gl, style=gs)
+    t.append(" · net ", style="dim")
+    t.append(f"{nl}\n", style=ns)
     t.append("Disk\n", style="dim")
     t.append("  R ", style="green")
     t.append(f"{_rate(s.get('disk_read'))}\n", style="bold")
+    t.append(f"    {_iops(s.get('disk_rops'))} · {_ms(s.get('disk_rlat'))}\n", style="dim")
     t.append("  W ", style="blue")
     t.append(f"{_rate(s.get('disk_write'))}\n", style="bold")
+    t.append(f"    {_iops(s.get('disk_wops'))} · {_ms(s.get('disk_wlat'))}\n", style="dim")
     return t