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