From c1ee7cf53886ce2f942cf82330c71f0e0c3a6117 Mon Sep 17 00:00:00 2001 From: Bin Le Date: Wed, 12 Aug 2026 20:05:19 -0400 Subject: [PATCH] Release v1.9.0: net/disk mirrored chart sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the net/disk I/O rates out of the Memory box into their own Network and Disk sections, each drawing a mirrored in-over-out chart pair sharing an implicit zero axis at the seam. - analytics: io_rate_percent() + NET/DISK_RATE_FLOOR_BPS. Charts are always auto-scaled (no SoC reference throughput exists for a NIC or NVMe controller); both directions of a box share one rolling-peak denominator so the traces stay comparable, floored so idle chatter does not paint full scale. - BrailleChart: fill="down" renders a top-anchored mirror via a second cumulative bit table. Exact in dots mode; block mode quantizes to 2 levels/row, since Block Elements ships only the half and full upper fills and the U+1FB82/U+1FB85 quarter-blocks are missing from many terminal fonts. - Each direction carries its own avg/max, replacing the combined suffix. - Sections hide themselves when the platform reports no counters. - Cover page: version badge and footer (both had a doubled "v"), plus a net/disk feature card. Export output is unchanged — presentation only. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 39 ++++ CLAUDE.md | 6 +- actop/analytics.py | 28 ++- actop/tui/app.py | 15 +- actop/tui/widgets.py | 293 +++++++++++++++++++++++------ docs/ROADMAP.md | 11 +- docs/SPEC-system.md | 42 ++++- docs/cover.html | 10 +- pyproject.toml | 2 +- tests/test_analytics.py | 26 ++- tests/test_braille_chart_render.py | 52 ++++- tests/test_dashboard_metrics.py | 77 +++++--- uv.lock | 2 +- 13 files changed, 496 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5d6df..cef62fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +## [1.9.0] - 2026-08-12 + +### Changed + +- **Net / disk I/O get their own chart sections** (N2). The rates shipped in + 1.8.0 as two plain text rows tacked onto the bottom of the `Memory` box — the + only telemetry in the dashboard with no history rendering, and mis-filed + (network throughput is not a memory metric). They now own two sibling + sections, `Network` and `Disk`, each drawing a **mirrored pair** of charts — + the inbound trace (`↓ In` / `↓ Read`) grows upward and the outbound one + (`↑ Out` / `↑ Write`) hangs downward directly beneath it, so the seam where + they meet reads as a shared zero axis (the btop/vnstat convention) without + spending a row drawing one. Labels sandwich the pair, so each direction keeps + its own `avg · max` at the same box height two separate label+chart stacks + would cost: + - The grid preset gains a third paired row (`Network` | `Disk`); `Power` + keeps its full-width span beneath them. Each section hides itself entirely + when the platform reports no counters, so `Power` closes the gap on a + machine without them. + - Charts are always auto-scaled — there is no SoC reference throughput for a + NIC or NVMe controller, so `--power-scale profile` has no analogue here. + New L2 helper `analytics.io_rate_percent()` divides by + `max(floor, rolling_peak x1.25)`, reusing the multiplier `--power-scale + auto` already uses. Both directions of a box share one denominator, so an + upload trickle never renders as tall as a saturating download. + - New floors `NET_RATE_FLOOR_BPS` (1 MB/s) and `DISK_RATE_FLOOR_BPS` + (10 MB/s) stop idle background chatter (mDNS keepalives, APFS journaling) + from dividing by itself and painting a full-scale chart. + - Each direction now carries its own `avg N · max N` context, replacing the + combined `avg ↓N ↑N` suffix of the single-row form. + - `BrailleChart` gains a `fill` direction. `down` anchors the trace at the + top edge via a second cumulative bit table — exact in `dots` mode; in + `block` mode the downward half quantizes to 2 levels per row, since Block + Elements ships only `▀` and `█` as upper fills and the quarter-blocks at + U+1FB82/U+1FB85 are missing from many terminal fonts. + - No alert threshold added: neither bus has a knowable ceiling to saturate + against, so `AlertEngine` is untouched. Export output is unchanged — this + is a presentation-only change. + ## [1.8.0] - 2026-08-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 34a6930..6d6e870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,16 +33,16 @@ This file is the single source of truth for repository guidelines, used by Claud | `actop/gpu_registry.py` | GPU accelerator registry reads via IOKit ctypes bindings: `get_gpu_time_by_pid()` sums `accumulatedGPUTime` off each `AGXDeviceUserClient`; `get_gpu_perf_stats()` reads device-level Device/Renderer/Tiler utilization % off the accelerator's own `PerformanceStatistics` dict (driver point reads — a fallback for `gpu_util_pct` plus the compute-vs-geometry breakdown IOReport cannot express; see `docs/SPEC-system.md` §3.8) | | `actop/disk_registry.py` | Disk I/O via IOKit ctypes bindings: `read_disk_totals()` sums cumulative `Statistics` counters across `AppleAPFSVolume` entries (`"Bytes read/written from block device"`), falling back to `IOBlockStorageDriver` on non-APFS; cumulative counters are deltas in the sampler — see `docs/SPEC-system.md` §2.4 | | `actop/utils.py` | L1 acquisition + platform discovery: native `ctypes` RAM/swap metrics and per-process CPU/GPU time collection (via `native_sys.py`), `sysctl`/`system_profiler` SoC info. Byte quantities cross layer seams as **raw bytes** (exact, prefix-free, Prometheus base-unit convention); GiB/MiB formatting is presentation and lives in `tui/`, and bandwidth stays decimal `GB/s` because that is Apple's unit for the bus — see `docs/SPEC-system.md` §2.1. Holds no derived domain math | -| `actop/analytics.py` | L2 domain analytics over acquired data points (imports only `models`/`power_scaling`, never `tui/*`): per-process power attribution (`attribute_power`), throttle detection (`domain_throttling`), bandwidth/package-power normalization, and the stateful `AlertEngine` (sustain-counted alerts + session-energy integral → `AlertFrame`) | +| `actop/analytics.py` | L2 domain analytics over acquired data points (imports only `models`/`power_scaling`, never `tui/*`): per-process power attribution (`attribute_power`), throttle detection (`domain_throttling`), bandwidth/package-power/I-O-rate normalization (`io_rate_percent` + the net/disk chart floors), and the stateful `AlertEngine` (sustain-counted alerts + session-energy integral → `AlertFrame`) | | `actop/soc_profiles.py` | 16 built-in M1–M4 SoC profiles with power/bandwidth reference values; tier fallbacks for unknown chips | | `actop/power_scaling.py` | Power chart scaling: `auto` mode (rolling peak x1.25) vs `profile` mode (SoC reference wattage) | | `actop/config.py` | `DashboardConfig` frozen dataclass; `create_dashboard_config()` merges CLI args with SoC info | | `actop/models.py` | `SystemSnapshot`, `CoreSample`, `ProcessSample` dataclasses (public API types) | | `actop/api.py` | `Monitor`, `Profiler`, `AsyncMonitor` — public Python API for hardware profiling; `Monitor(include_processes=True)` opts into watt-attributed `ProcessSample`s | | `actop/tui/app.py` | `ActopApp`: Textual `App` with polling worker, process table, app-level status bar (fed by `AlertsComputed`), interactive sort/filter/pause; `l` cycles the layout preset, `c` toggles the per-core panels, and `--layout`/`process-table` width live at app level | -| `actop/tui/widgets.py` | `HardwareDashboard` widget plus the custom `BrailleChart` sparkline widget, core rows, and alert computation. Composes five titled sections (`P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Power`) and owns the `grid`/`stack` layout presets (`set_layout_preset`, width auto-degrade below ~96 cols) plus the runtime per-core toggle (`set_show_cores`, hidden by default); posts the status string up via `AlertsComputed`. TUI CSS is embedded inline as each component's `DEFAULT_CSS` (no separate `.tcss` file). | +| `actop/tui/widgets.py` | `HardwareDashboard` widget plus the custom `BrailleChart` sparkline widget, core rows, and alert computation. Composes seven titled sections (`P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Network`, `Disk`, `Power`) and owns the `grid`/`stack` layout presets (`set_layout_preset`, width auto-degrade below ~96 cols) plus the runtime per-core toggle (`set_show_cores`, hidden by default); posts the status string up via `AlertsComputed`. TUI CSS is embedded inline as each component's `DEFAULT_CSS` (no separate `.tcss` file). | -**Data flow**: `ioreport.py` provides ctypes bindings for IOReport snapshots and deltas → `sampler.py` subscribes to Energy Model / CPU Stats / GPU Stats channels, computes deltas between consecutive snapshots, reads SMC die temperatures via `smc.py`, and converts raw items into a `SampleResult` (power in watts, frequency in MHz, activity in percent, temperatures in °C) → `api.py` wraps the sampler in `Monitor` and maps `SampleResult` to `SystemSnapshot` (including `CoreSample` lists; the driver's `gpu_registry.get_gpu_perf_stats()` Device/Renderer/Tiler read, which also selects `gpu_util_source` — residency stays primary unless the GPU DVFS table went unclassified; and, when `include_processes=True`, watt-attributed `ProcessSample` lists computed in L2 via `analytics.attribute_power`) → `SystemSnapshot` is the **sole per-frame contract**: `tui/widgets.py` feeds it into custom `BrailleChart` sparkline widgets (arranged in five titled sections — `P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Power` — under a `grid` or `stack` layout preset) and posts the composed thermal/alert status up to `tui/app.py` via an `AlertsComputed` message for the fixed app-level status bar; `tui/app.py` reads processes off the snapshot and drives the render loop (the TUI imports no L1 acquisition/attribution). +**Data flow**: `ioreport.py` provides ctypes bindings for IOReport snapshots and deltas → `sampler.py` subscribes to Energy Model / CPU Stats / GPU Stats channels, computes deltas between consecutive snapshots, reads SMC die temperatures via `smc.py`, and converts raw items into a `SampleResult` (power in watts, frequency in MHz, activity in percent, temperatures in °C) → `api.py` wraps the sampler in `Monitor` and maps `SampleResult` to `SystemSnapshot` (including `CoreSample` lists; the driver's `gpu_registry.get_gpu_perf_stats()` Device/Renderer/Tiler read, which also selects `gpu_util_source` — residency stays primary unless the GPU DVFS table went unclassified; and, when `include_processes=True`, watt-attributed `ProcessSample` lists computed in L2 via `analytics.attribute_power`) → `SystemSnapshot` is the **sole per-frame contract**: `tui/widgets.py` feeds it into custom `BrailleChart` sparkline widgets (arranged in seven titled sections — `P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Network`, `Disk`, `Power` — under a `grid` or `stack` layout preset; the two I/O sections hide themselves when the platform exposes no counters) and posts the composed thermal/alert status up to `tui/app.py` via an `AlertsComputed` message for the fixed app-level status bar; `tui/app.py` reads processes off the snapshot and drives the render loop (the TUI imports no L1 acquisition/attribution). **SoC compatibility**: Explicit M1–M4 recognition (16 profiles). Unknown future chips fall back to tier defaults (base/Pro/Max/Ultra) using the latest known generation's reference values. diff --git a/actop/analytics.py b/actop/analytics.py index 9cd0cbe..bd84cd2 100644 --- a/actop/analytics.py +++ b/actop/analytics.py @@ -11,7 +11,16 @@ from collections import deque from dataclasses import dataclass -from actop.power_scaling import clamp_percent +from actop.power_scaling import DEFAULT_AUTO_MULTIPLIER, clamp_percent + +# Chart denominator floors for the network / disk I/O rates (bytes/s). Without a +# floor an idle machine's background chatter (mDNS keepalives, spotlight +# indexing, APFS journaling) divides by its own peak and paints a full-scale +# chart — pure noise amplification. Disk sits an order of magnitude higher than +# net because APFS idles far above network keepalives and NVMe peaks are in the +# GB/s range. +NET_RATE_FLOOR_BPS = 1_000_000.0 +DISK_RATE_FLOOR_BPS = 10_000_000.0 def attribute_power(share_cpu, share_gpu, cpu_watts, gpu_watts): @@ -75,6 +84,23 @@ def bandwidth_percent(snapshot, max_total_bw): return clamp_percent(snapshot.bandwidth_gbps / max(max_total_bw, 1.0) * 100) +def io_rate_percent( + rate_bps, peak_bps, floor_bps, auto_multiplier=DEFAULT_AUTO_MULTIPLIER +): + """Normalize a network/disk byte-rate to a 0-100 chart percent. + + Always auto-scaled: the denominator is the rolling peak plus headroom + (the same multiplier `--power-scale auto` uses), floored at `floor_bps`. + Unlike `power_to_percent` there is no `profile` mode — no SoC reference + throughput exists for a NIC or NVMe controller. Callers pass one peak for + both directions of a box so the two charts stay visually comparable. + """ + denominator = max(float(floor_bps), float(peak_bps or 0.0) * auto_multiplier) + if denominator <= 0: + return 0 + return clamp_percent(float(rate_bps or 0.0) / denominator * 100.0) + + def package_power_percent(snapshot, package_ref_w): """Package power as a percent of the SoC reference rail. diff --git a/actop/tui/app.py b/actop/tui/app.py index 0cd404e..d8188da 100644 --- a/actop/tui/app.py +++ b/actop/tui/app.py @@ -123,9 +123,9 @@ def _process_display_name(command, max_len=24): [b]Layout presets[/b] grid Two columns (default): P-CPU / E-CPU cluster boxes share the top - row, GPU·ANE / Memory the next, and Power spans the full width - beneath. Fits short terminals without scrolling. Falls back to - stack automatically under ~96 cols. + row, GPU·ANE / Memory the next, Network / Disk the third, and + Power spans the full width beneath. Fits short terminals without + scrolling. Falls back to stack automatically under ~96 cols. stack Single full-width column — longest chart history span; scrolls on tall dashboards while the status line stays fixed. @@ -147,6 +147,8 @@ def _process_display_name(command, max_len=24): ANE Apple Neural Engine util% (estimated) and power RAM Used / total memory (and swap when active) Mem BW Unified-memory bandwidth in GB/s (hidden if unavailable) + ↓ In / ↑ Out Network receive / transmit throughput (Network box) + ↓ Read / ↑ Write Disk read / write throughput (Disk box) CPU / GPU Live CPU/GPU rail power (W) with an inline sparkline Package Power Total SoC power draw in watts (CPU + GPU + ANE + other rails) idle/low/mid/high DVFS residency: % of time since the last sample spent @@ -154,6 +156,13 @@ def _process_display_name(command, max_len=24): frequency (not just the instantaneous clock). Hidden with --no-show-residency + Both I/O boxes draw a mirrored pair of charts: the inbound trace grows up + from the middle, the outbound one hangs down from it, so the seam where they + meet is a shared zero axis. Both directions share one scale, always + auto-scaled against this session's rolling peak rather than a fixed ceiling + (there is no reference throughput for a NIC or SSD). Each box hides itself + entirely when no counters are available. + [b]Process table[/b] CPU% Per-process CPU utilization (Δ CPU-time over the interval) diff --git a/actop/tui/widgets.py b/actop/tui/widgets.py index 59ea0a3..5671d0f 100644 --- a/actop/tui/widgets.py +++ b/actop/tui/widgets.py @@ -10,7 +10,14 @@ from textual.widget import Widget from textual.widgets import Static -from actop.analytics import AlertEngine, bandwidth_percent, package_power_percent +from actop.analytics import ( + DISK_RATE_FLOOR_BPS, + NET_RATE_FLOOR_BPS, + AlertEngine, + bandwidth_percent, + io_rate_percent, + package_power_percent, +) from actop.models import SystemSnapshot from actop.power_scaling import ( DEFAULT_CPU_FLOOR_W, @@ -70,10 +77,29 @@ # doubling horizontal density (btop-style) instead of leaving it blank. _BRAILLE_FILL_BITS_R = [0x80, 0xA0, 0xB0, 0xB8] _BRAILLE_FULL_R = 0xB8 # all 4 right-column dots +# Mirror poles for a top-down fill (the `down` half of the I/O mirror charts): +# the same cumulative ramps accumulating from the top dot instead of the bottom +# \u2014 left dots 1 / 1+2 / 1+2+3 / 1+2+3+7, right dots 4 / 4+5 / 4+5+6 / 4+5+6+8. +# The all-4-dots value is necessarily the same in both directions. +_BRAILLE_FILL_BITS_DOWN = [0x01, 0x03, 0x07, 0x47] +_BRAILLE_FILL_BITS_DOWN_R = [0x08, 0x18, 0x38, 0xB8] _BRAILLE_BLANK = "\u2800" _BLOCK_FILL_GLYPHS = ["\u2582", "\u2584", "\u2586", "\u2588"] +# Top-down block ramp. Block Elements only ships two upper-fill glyphs that are +# universally available in terminal fonts \u2014 \u2580 (half) and \u2588 (full) \u2014 so the +# downward half quantizes to 2 levels per row instead of 4, mapped to the +# nearest available fill. The 1/4 and 3/4 upper blocks live in Symbols for +# Legacy Computing (U+1FB82/U+1FB85), which many fonts render as tofu; a coarser +# but always-legible ramp beats a finer one that may not draw at all. +_BLOCK_FILL_GLYPHS_DOWN = ["\u2580", "\u2580", "\u2588", "\u2588"] _BLOCK_FULL_GLYPH = "\u2588" _BLOCK_BLANK = " " +# Fill directions a chart can render in. `up` is the normal bottom-anchored +# sparkline; `down` hangs the trace from the top edge so a `down` chart placed +# directly beneath an `up` one forms a mirrored pair sharing an implicit zero +# axis where they meet (see the Network / Disk sections). +_FILL_UP = "up" +_FILL_DOWN = "down" # Fan row spinner: one braille-cascade glyph per fan, prefixed to its RPM # reading (same 10-frame cascade as the splash screen's _SPINNER_FRAMES in @@ -205,14 +231,17 @@ def _normalize_chart_glyph_mode(value: str) -> str: return "block" if str(value).strip().lower() == "block" else "dots" -def _glyph_set_for_mode(mode: str) -> tuple[str, str, list[str]]: +def _glyph_set_for_mode(mode: str, fill: str = _FILL_UP) -> tuple[str, str, list[str]]: normalized = _normalize_chart_glyph_mode(mode) + down = fill == _FILL_DOWN if normalized == "block": - return (_BLOCK_BLANK, _BLOCK_FULL_GLYPH, _BLOCK_FILL_GLYPHS) + glyphs = _BLOCK_FILL_GLYPHS_DOWN if down else _BLOCK_FILL_GLYPHS + return (_BLOCK_BLANK, _BLOCK_FULL_GLYPH, glyphs) + bits = _BRAILLE_FILL_BITS_DOWN if down else _BRAILLE_FILL_BITS return ( _BRAILLE_BLANK, chr(0x2800 | _BRAILLE_FULL), - [chr(0x2800 | bits) for bits in _BRAILLE_FILL_BITS], + [chr(0x2800 | b) for b in bits], ) @@ -224,32 +253,46 @@ def _clamped_value_and_level(value: float, total_levels: int) -> tuple[float, in return (v, level) -def _braille_column_bits(level: int, row: int, height: int, fill, full: int) -> int: +def _braille_column_bits( + level: int, row: int, height: int, fill, full: int, down: bool = False +) -> int: """Braille bits for one vertical dot column of the cell at terminal `row`. - `level` is the sample's 0..height*4 fill height (bottom-up). `fill`/`full` - are the cumulative-partial list and all-4-dots value for the target column + `level` is the sample's 0..height*4 fill height. `fill`/`full` are the + cumulative-partial list and all-4-dots value for the target column (left = `_BRAILLE_FILL_BITS`/`_BRAILLE_FULL`, right = the `*_R` pair). + `down` anchors the fill at the top edge instead of the bottom — the trace + hangs downward, mirroring the normal sparkline about its top row. """ if level <= 0: return 0 - dot_row = height - 1 - (level - 1) // 4 - if row > dot_row: - return full + if down: + dot_row = (level - 1) // 4 + if row < dot_row: + return full + else: + dot_row = height - 1 - (level - 1) // 4 + if row > dot_row: + return full if row == dot_row: return fill[(level - 1) % 4] return 0 -def _braille_cell_bits(llevel: int, rlevel: int, row: int, height: int) -> int: +def _braille_cell_bits( + llevel: int, rlevel: int, row: int, height: int, down: bool = False +) -> int: """Braille bits for one dense cell: left column = earlier sample `llevel`, right column = later sample `rlevel`. Single source of truth for the 2-sample packing shared by BrailleChart._render_dots (height rows) and _inline_spark - (height 1). `llevel`/`rlevel` are 0..height*4 fill heights. + (height 1). `llevel`/`rlevel` are 0..height*4 fill heights; `down` renders + the mirrored, top-anchored fill. """ + lfill = _BRAILLE_FILL_BITS_DOWN if down else _BRAILLE_FILL_BITS + rfill = _BRAILLE_FILL_BITS_DOWN_R if down else _BRAILLE_FILL_BITS_R return _braille_column_bits( - llevel, row, height, _BRAILLE_FILL_BITS, _BRAILLE_FULL - ) | _braille_column_bits(rlevel, row, height, _BRAILLE_FILL_BITS_R, _BRAILLE_FULL_R) + llevel, row, height, lfill, _BRAILLE_FULL, down + ) | _braille_column_bits(rlevel, row, height, rfill, _BRAILLE_FULL_R, down) def _value_to_cell_glyph(value: float, glyph_mode: str) -> str: @@ -305,11 +348,19 @@ def __init__( glyph_mode: str = "dots", color_mode: str | None = None, palette: str = _DEFAULT_PALETTE, + fill: str = _FILL_UP, **kwargs, ) -> None: super().__init__(**kwargs) self._data = [] self._glyph_mode = _normalize_chart_glyph_mode(glyph_mode) + # Fill direction, fixed at construction: `down` hangs the trace from the + # top edge so this chart mirrors an `up` chart stacked above it. + if fill not in (_FILL_UP, _FILL_DOWN): + raise ValueError( + f"fill must be {_FILL_UP!r} or {_FILL_DOWN!r}, got {fill!r}" + ) + self._fill = fill # None => resolve lazily from the running app's console (and NO_COLOR) # once mounted; falls back to environment detection before then. self._color_mode = color_mode @@ -388,7 +439,9 @@ def _render_dots(self, width: int, height: int, color_mode: str): rv, rlevel = _clamped_value_and_level( float(self._data[ri]) if ri >= 0 else 0.0, total_levels=total ) - bits = _braille_cell_bits(llevel, rlevel, row, height) + bits = _braille_cell_bits( + llevel, rlevel, row, height, self._fill == _FILL_DOWN + ) if bits: line_color = _pct_to_color(max(lv, rv), color_mode, self._palette) out.append(chr(0x2800 | bits), style=line_color) @@ -400,7 +453,10 @@ def _render_dots(self, width: int, height: int, color_mode: str): def _render_block(self, width: int, height: int, color_mode: str): """Block-glyph render: 1 time sample per character (no column packing).""" - blank_glyph, full_glyph, partial_glyphs = _glyph_set_for_mode(self._glyph_mode) + down = self._fill == _FILL_DOWN + blank_glyph, full_glyph, partial_glyphs = _glyph_set_for_mode( + self._glyph_mode, self._fill + ) n = width # 1 sample per character dlen = len(self._data) offset = dlen - n @@ -413,9 +469,11 @@ def _render_block(self, width: int, height: int, color_mode: str): v, level = _clamped_value_and_level(raw_v, total_levels=total) line_color = _pct_to_color(v, color_mode, self._palette) if level > 0: - dot_row = height - 1 - (level - 1) // 4 - if row > dot_row: - # below the peak row: fully filled segment + dot_row = ( + (level - 1) // 4 if down else height - 1 - (level - 1) // 4 + ) + if (row < dot_row) if down else (row > dot_row): + # between the peak row and the anchored edge: full cell out.append(full_glyph, style=line_color) elif row == dot_row: # peak row: partial fill @@ -497,7 +555,9 @@ class HardwareDashboard(Widget): # Dashboard CSS lives here (scoped to this widget), not in ActopApp: the two # layout presets are just a class swap on this widget. `grid` is a two-column # grid — the P-CPU / E-CPU cluster boxes share the top row, GPU·ANE / Memory - # the second, and Power spans the full width beneath them; `stack` is the + # the second, Network / Disk the third (either or both hidden when the + # platform exposes no counters), and Power spans the full width beneath + # them; `stack` is the # single scrollable column (only the stack preset scrolls — grid is sized to # fit). Below `_GRID_MIN_WIDTH` cols grid auto-degrades to stack (`on_resize`), # so a grid never squeezes its columns below readability. @@ -515,7 +575,7 @@ class HardwareDashboard(Widget): layout: grid; grid-size: 2; grid-columns: 1fr 1fr; - grid-rows: auto auto auto; + grid-rows: auto auto auto auto; } /* Power is a single wide chart, so it spans both columns on the bottom row instead of leaving a half-empty cell beside it. */ @@ -530,7 +590,9 @@ class HardwareDashboard(Widget): HardwareDashboard.layout-grid #section-pcpu, HardwareDashboard.layout-grid #section-ecpu, HardwareDashboard.layout-grid #section-gpu-ane, - HardwareDashboard.layout-grid #section-memory { + HardwareDashboard.layout-grid #section-memory, + HardwareDashboard.layout-grid #section-net, + HardwareDashboard.layout-grid #section-disk { height: 100%; } .dash-section { @@ -627,6 +689,14 @@ def __init__(self, config, **kwargs) -> None: self._disk_read_hist: deque = deque([0] * maxlen, maxlen=maxlen) self._disk_write_hist: deque = deque([0] * maxlen, maxlen=maxlen) + # Chart percents for the I/O rates above, normalised against a rolling + # peak (see _append_io_percents) — the same native/percent deque pairing + # as _bw_gbps_hist / _bw_hist. + self._net_rx_pct_hist: deque = deque([0] * maxlen, maxlen=maxlen) + self._net_tx_pct_hist: deque = deque([0] * maxlen, maxlen=maxlen) + self._disk_read_pct_hist: deque = deque([0] * maxlen, maxlen=maxlen) + self._disk_write_pct_hist: deque = deque([0] * maxlen, maxlen=maxlen) + # Count of real samples appended; histories are zero-padded for chart # right-alignment, so avg/max must ignore the leading padding. self._sample_count: int = 0 @@ -835,12 +905,57 @@ def compose(self) -> ComposeResult: id="bw-chart", classes="metric-chart", ) - # Network / disk I/O rates (bytes/s). Hidden when unavailable - # (non-Darwin or no usable counters), gated per-snapshot via - # SystemSnapshot.net_available / disk_available — same hide-row - # pattern as Mem BW / Fan below. - yield Static("Net 0 B/s", id="net-label", classes="metric-label") - yield Static("Disk 0 B/s", id="disk-label", classes="metric-label") + + # Network / disk I/O each own a section rather than trailing the Memory + # box: network throughput is not a memory metric, and the rates deserve + # the same history rendering every other metric family gets. Both start + # hidden and are revealed by the first snapshot that reports counters + # (SystemSnapshot.net_available / disk_available) — the same + # start-hidden treatment as #gpu-rt-row, so a machine with no counters + # never shows an empty box. + # Each I/O box is a mirror pair: the inbound chart fills upward, the + # outbound one directly beneath it fills downward, so the seam where + # they meet reads as a shared zero axis (btop/vnstat convention) without + # spending a row on drawing one. The labels sandwich the pair — inbound + # above, outbound below — which keeps each direction's own avg/max at + # the same total height as two separate label+chart stacks. + with Vertical(id="section-net", classes="dash-section") as net_sec: + net_sec.border_title = "Network" + net_sec.display = False + yield Static("↓ In 0 B/s", id="net-rx-label", classes="metric-label") + yield BrailleChart( + glyph_mode=self._chart_glyph, + palette=self._palette, + id="net-rx-chart", + classes="metric-chart", + ) + yield BrailleChart( + glyph_mode=self._chart_glyph, + palette=self._palette, + fill=_FILL_DOWN, + id="net-tx-chart", + classes="metric-chart", + ) + yield Static("↑ Out 0 B/s", id="net-tx-label", classes="metric-label") + + with Vertical(id="section-disk", classes="dash-section") as disk_sec: + disk_sec.border_title = "Disk" + disk_sec.display = False + yield Static("↓ Read 0 B/s", id="disk-read-label", classes="metric-label") + yield BrailleChart( + glyph_mode=self._chart_glyph, + palette=self._palette, + id="disk-read-chart", + classes="metric-chart", + ) + yield BrailleChart( + glyph_mode=self._chart_glyph, + palette=self._palette, + fill=_FILL_DOWN, + id="disk-write-chart", + classes="metric-chart", + ) + yield Static("↑ Write 0 B/s", id="disk-write-label", classes="metric-label") with Vertical(id="section-power", classes="dash-section") as pwr_sec: pwr_sec.border_title = "Power" @@ -1045,6 +1160,7 @@ def update_metrics(self, message: MetricsUpdated) -> None: self._net_tx_hist.append(s.net_tx_bps if s.net_available else 0.0) self._disk_read_hist.append(s.disk_read_bps if s.disk_available else 0.0) self._disk_write_hist.append(s.disk_write_bps if s.disk_available else 0.0) + self._append_io_percents() # Update charts chart_data = ( @@ -1054,6 +1170,10 @@ def update_metrics(self, message: MetricsUpdated) -> None: ("#ane-chart", self._ane_hist), ("#ram-chart", self._ram_hist), ("#bw-chart", self._bw_hist), + ("#net-rx-chart", self._net_rx_pct_hist), + ("#net-tx-chart", self._net_tx_pct_hist), + ("#disk-read-chart", self._disk_read_pct_hist), + ("#disk-write-chart", self._disk_write_pct_hist), ("#pkgpwr-chart", self._pkgpwr_hist), ) for widget_id, data in chart_data: @@ -1153,26 +1273,34 @@ def update_metrics(self, message: MetricsUpdated) -> None: f"Mem BW {s.bandwidth_gbps:.1f} GB/s{self._gbps_stats_suffix(self._bw_gbps_hist)}" ) - # Network I/O: hide the row when getifaddrs returns no usable counters. - # Rates are bytes/s; displayed as human-readable with rolling context. - net_label = self.query_one("#net-label", Static) - if net_label.display != s.net_available: - net_label.display = s.net_available - if s.net_available: - net_label.update( - f"Net ↓ {_format_bps(s.net_rx_bps)} ↑ {_format_bps(s.net_tx_bps)}" - f"{self._bps_stats_suffix(self._net_rx_hist, self._net_tx_hist)}" - ) + # Network I/O: hide the section when getifaddrs returns no usable + # counters. Rates are bytes/s; displayed as human-readable with rolling + # context. + self._update_io_section( + "#section-net", + s.net_available, + ( + ("#net-rx-label", "↓ In", s.net_rx_bps, self._net_rx_hist), + ("#net-tx-label", "↑ Out", s.net_tx_bps, self._net_tx_hist), + ), + ) - # Disk I/O: hide the row when no volume/driver exposes Statistics. - disk_label = self.query_one("#disk-label", Static) - if disk_label.display != s.disk_available: - disk_label.display = s.disk_available - if s.disk_available: - disk_label.update( - f"Disk R {_format_bps(s.disk_read_bps)} W {_format_bps(s.disk_write_bps)}" - f"{self._bps_stats_suffix(self._disk_read_hist, self._disk_write_hist)}" - ) + # Disk I/O: hide the section when no volume/driver exposes Statistics. + # Read takes the upward half and write the downward one, the same + # inbound/outbound axis the network mirror uses. + self._update_io_section( + "#section-disk", + s.disk_available, + ( + ("#disk-read-label", "↓ Read", s.disk_read_bps, self._disk_read_hist), + ( + "#disk-write-label", + "↑ Write", + s.disk_write_bps, + self._disk_write_hist, + ), + ), + ) # Fan RPM: hide the row entirely on fanless Macs (no SMC fan keys), # mirroring the Mem BW hide-on-unavailable pattern above. Per-fan @@ -1256,16 +1384,71 @@ def _gbps_stats_suffix(self, hist) -> str: avg, mx = self._avg_max(hist) return f" avg {avg:.1f} · max {mx:.1f} GB/s" - def _bps_stats_suffix(self, rx_hist, tx_hist) -> str: - """` avg ↓ N · ↑ N` context for network/disk byte-rate histories. + def _bps_stats_suffix(self, hist) -> str: + """` avg N · max N` context string for a byte-rate history. - Shows the combined rx+tx average and peak in human-readable form. - The suffix is shorter than the per-metric versions because the label - already carries two readings (↓/↑ or R/W). + One direction per call: each direction now owns a labelled chart row, + so the suffix carries that direction's own rolling context (matching + every other stats suffix here) instead of a combined rx+tx figure. """ - rx_avg, _ = self._avg_max(rx_hist) - tx_avg, _ = self._avg_max(tx_hist) - return f" avg ↓{_format_bps(rx_avg)} ↑{_format_bps(tx_avg)}" + avg, mx = self._avg_max(hist) + return f" avg {_format_bps(avg)} · max {_format_bps(mx)}" + + def _append_io_percents(self) -> None: + """Append this frame's chart percents for the four I/O rate histories. + + Both directions of a box share one denominator (the rolling peak across + rx+tx / read+write), so an upload trickle never renders as tall as a + saturating download — the whole point of stacking them in one box is + comparison. Called once per frame, after the native-unit deques have + been appended, so this frame's rate is included in its own peak. + """ + for pct_hist, rate_hist, other_hist, floor in ( + ( + self._net_rx_pct_hist, + self._net_rx_hist, + self._net_tx_hist, + NET_RATE_FLOOR_BPS, + ), + ( + self._net_tx_pct_hist, + self._net_tx_hist, + self._net_rx_hist, + NET_RATE_FLOOR_BPS, + ), + ( + self._disk_read_pct_hist, + self._disk_read_hist, + self._disk_write_hist, + DISK_RATE_FLOOR_BPS, + ), + ( + self._disk_write_pct_hist, + self._disk_write_hist, + self._disk_read_hist, + DISK_RATE_FLOOR_BPS, + ), + ): + peak = max(max(rate_hist), max(other_hist)) + pct_hist.append(io_rate_percent(rate_hist[-1], peak, floor)) + + def _update_io_section(self, section_id: str, available: bool, rows) -> None: + """Show/hide one I/O section and refresh its labelled rate rows. + + `rows` is a sequence of `(label_id, prefix, rate_bps, native_hist)`. + Availability is effectively constant per session, so `display` is only + written on change (the same pattern as Mem BW / Fan); the section starts + hidden, so the first available frame is what reveals it. + """ + section = self.query_one(section_id, Vertical) + if section.display != available: + section.display = available + if not available: + return + for label_id, prefix, rate_bps, native_hist in rows: + self.query_one(label_id, Static).update( + f"{prefix} {_format_bps(rate_bps)}{self._bps_stats_suffix(native_hist)}" + ) def _update_cluster_summary_row( self, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ebecb3d..1e60f12 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,11 +12,16 @@ utilization, per-core frequency, memory/bandwidth, thermal state, fan RPM, per-p time, SMC die temperatures; NDJSON/Prometheus export; Textual dashboard with sparklines; public Python API (`Monitor`, `Profiler`, `AsyncMonitor`); Homebrew + PyPI distribution; 16 built-in M1–M4 SoC profiles; accessibility palettes (`--palette`). Net/disk I/O -shipped v1.8.0. No menu-bar mode, no in-app update notice. +shipped v1.8.0, with their own chart sections since v1.9.0. No menu-bar mode, +no in-app update notice. ## State — 2026-08 -- **Shipped:** v1.8.0 — **net/disk I/O (N track):** getifaddrs/if_data network + +- **Shipped:** v1.9.0 — **net/disk chart sections (N2):** the 1.8.0 text rows + promoted out of the `Memory` box into their own `Network` / `Disk` sections, + each a mirrored in-over-out chart pair, auto-scaled against a shared rolling + peak (`analytics.io_rate_percent`, peak x1.25 with a noise floor); + v1.8.0 — **net/disk I/O (N track):** getifaddrs/if_data network + IOKit AppleAPFSVolume disk rates, surfaced in TUI/NDJSON/Prometheus; plus the **reading-plane audit closes (F track):** deprecated `*_gb`/`*_GB`/`rss_mb`/ `*_gigabytes` aliases and the `--alert-swap-rise-gb` CLI alias removed; @@ -25,7 +30,7 @@ shipped v1.8.0. No menu-bar mode, no in-app update notice. alerts/throttle/session energy reach both backends; v1.7.1 — `--theme` flag + live theme cycling; layering cleanup LC-1→LC-3; fan RPM, per-process GPU, accessibility palettes, GPU Renderer/Tiler via `IOAccelerator`, reading-plane - audit. → `CHANGELOG.md` `[1.2.x]`–`[1.8.0]` + audit. → `CHANGELOG.md` `[1.2.x]`–`[1.9.0]` - **Board:** one open track — **S** sudo elevation for per-process GPU/energy attribution. **First move is S1:** the `--sudo` re-exec + `u`-key password modal per `docs/TODO-sudo-mode.md`. **E** (export parity) shipped v1.7.2; diff --git a/docs/SPEC-system.md b/docs/SPEC-system.md index 1a15b76..4d3d962 100644 --- a/docs/SPEC-system.md +++ b/docs/SPEC-system.md @@ -172,8 +172,8 @@ SDK bump. **Availability** — `net_available`/`disk_available` on `SystemSnapshot` mirror `bandwidth_available`: `False` (with zeroed rates) on non-Darwin or when no -usable counters exist; the TUI hides the row rather than render a phantom -`0 B/s`. `hw.memsize`/`hw.pagesize` are module-cached (fixed until reboot) so +usable counters exist; the TUI hides the whole `Network` / `Disk` section +(§6.5) rather than render a phantom `0 B/s`. `hw.memsize`/`hw.pagesize` are module-cached (fixed until reboot) so the per-tick sysctl floor stays low under the added reader calls. --- @@ -331,12 +331,21 @@ Keybindings: `q` quit, `Space` pause, `s` sort, `g` glyph toggle, `l` layout cycle (grid/stack), `c` cores toggle, `p` process table, `t` theme cycle (8 themes), `/` regex filter, `?` help. +`grid` is four rows: P-CPU | E-CPU, GPU·ANE | Memory, Network | Disk, then +Power spanning both columns. Paired boxes are height-matched so their bottom +borders align; below `_GRID_MIN_WIDTH` (96 cols) grid degrades to `stack`. + All letter actions answer uppercase (Caps Lock/Shift — same Textual key). ### 6.2 Sparklines (`BrailleChart`) Unicode Braille (`⠀`–`⣿`, 2 samples/char in `dots` mode) or -Block (`▂`–`█`, 1 sample/char in `block` mode). Color gradient: +Block (`▂`–`█`, 1 sample/char in `block` mode). `fill="down"` mirrors the +trace about its top edge (top-anchored, hanging downward) — used for the lower +half of the I/O mirror charts (§6.5). In `dots` the mirror is exact (a second +cumulative bit table); in `block` the downward half quantizes to 2 levels per +row, since Block Elements ships only `▀` and `█` as upper fills and the +quarter-blocks at U+1FB82/U+1FB85 are absent from many terminal fonts. Color gradient: blue→red, with palette selection (`thermal`/`viridis`/`mono` via `--palette`) and terminal-color-tier degradation (truecolor→256→16→none, honoring `NO_COLOR`). @@ -360,7 +369,32 @@ sustain-counted (default 3 samples). Session energy displayed as status-line tokens (`span`, `energy`, `THERMAL`, `THROTTLING:CPU/GPU`, `MEM-BOUND>`, `PKG>`, `SWAP+`), and `NO_COLOR` behavior. -### 6.5 Per-process power attribution (`PWR`) +### 6.5 Network / Disk I/O sections + +Two titled sections (`Network`, `Disk`), each a **mirrored pair**: the +inbound chart (`↓ In` / `↓ Read`) fills upward and the outbound one +(`↑ Out` / `↑ Write`) is stacked directly beneath it filling downward, so the +seam between them reads as a shared zero axis without spending a row drawing +one (the btop/vnstat convention). Labels sandwich the pair — inbound above, +outbound below — so each direction keeps its own `avg N · max N` at the same +box height two separate label+chart stacks would cost. Both start hidden and +are revealed by the first snapshot reporting `net_available` / +`disk_available`; a machine with no counters never shows an empty box. The two +hide independently: when only one reports counters the survivor occupies the +left grid column at half width, which is accepted rather than span-corrected +(a runtime `column-span` toggle for a rare case is not worth the machinery). + +Charts are **always** auto-scaled — there is no SoC reference throughput for a +NIC or NVMe controller, so `--power-scale profile` has no analogue here. +`analytics.io_rate_percent(rate, peak, floor)` divides by +`max(floor, rolling_peak x1.25)`, where the peak spans **both directions** of a +box so ↓/↑ stay visually comparable. The floors +(`NET_RATE_FLOOR_BPS` = 1 MB/s, `DISK_RATE_FLOOR_BPS` = 10 MB/s) exist to stop +idle background chatter (mDNS keepalives, APFS journaling) from dividing by +itself and painting a full-scale chart. No alert threshold: neither bus has a +knowable ceiling to saturate against, so `AlertEngine` is not involved. + +### 6.6 Per-process power attribution (`PWR`) CPU: `PWR_cpu = (pid CPU-time Δ / Σ CPU-time Δ) × cpu_watts`. GPU: `gpu_registry.get_gpu_time_by_pid()` → per-pid GPU-time Δ / Σ GPU-time Δ. diff --git a/docs/cover.html b/docs/cover.html index c56be5a..3195b03 100644 --- a/docs/cover.html +++ b/docs/cover.html @@ -1,4 +1,4 @@ - + @@ -166,7 +166,7 @@
- vv1.7.0 + v1.9.0

actop.

Watch your Apple Silicon Mac the way it actually works — sudoless, @@ -196,6 +196,10 @@

▮CPU · GPU · ANE

▮Memory bandwidth

Live GB/s against the SoC's peak, with a bandwidth-saturation alert and a MEM-BOUND indicator.

+
+

▮Network & disk I/O

+

Throughput on both buses as mirrored in-over-out charts, read unprivileged from getifaddrs and IOKit volume counters — no iostat subprocess.

+

▮Thermals & throttling

CPU/GPU die temperatures from the Apple SMC and the macOS thermal state, with a THROTTLING indicator.

@@ -271,7 +275,7 @@

actop — Apple Chip top

diff --git a/pyproject.toml b/pyproject.toml index 741351f..3441c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "actop" -version = "1.8.0" +version = "1.9.0" description = "Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference" readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 1abe778..03afb48 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -8,7 +8,14 @@ is the public entrypoint and `AlertFrame` fields are the public output. """ -from actop.analytics import AlertEngine, AlertFrame, domain_throttling +from actop.analytics import ( + DISK_RATE_FLOOR_BPS, + NET_RATE_FLOOR_BPS, + AlertEngine, + AlertFrame, + domain_throttling, + io_rate_percent, +) from actop.models import SystemSnapshot _IDLE_RESIDENCY = {"idle": 100, "low": 0, "mid": 0, "high": 0} @@ -229,3 +236,20 @@ def test_package_power_ceiling_ratchets_up_and_never_back_down(): # ratcheted reference, not the static construction-time floor. frame = engine.feed(_snapshot(package_watts=78.0)) assert frame.pkg_alert is True + + +def test_io_rate_percent_floor_keeps_idle_chatter_off_full_scale(): + # The I/O charts are always auto-scaled against their own rolling peak, so + # without a floor an idle machine's background chatter (mDNS keepalives, + # APFS journaling) would divide by itself and paint a full-scale chart — + # the noise-amplification failure the floors exist to prevent. + # + # Idle: 20 KB/s peaking at 20 KB/s stays near the baseline because the + # 1 MB/s net floor, not the peak, wins the denominator. + assert io_rate_percent(20_000.0, 20_000.0, NET_RATE_FLOOR_BPS) == 2 + # Real traffic above the floor scales against peak x1.25, so a sample *at* + # the session peak lands at 80% and leaves headroom for a higher one. + assert io_rate_percent(100_000_000.0, 100_000_000.0, NET_RATE_FLOOR_BPS) == 80 + # Disk's floor is an order of magnitude higher (APFS idles far above + # network keepalives), so the same 20 KB/s reads as flat there. + assert io_rate_percent(20_000.0, 20_000.0, DISK_RATE_FLOOR_BPS) == 0 diff --git a/tests/test_braille_chart_render.py b/tests/test_braille_chart_render.py index 9ade69d..8d36957 100644 --- a/tests/test_braille_chart_render.py +++ b/tests/test_braille_chart_render.py @@ -22,11 +22,12 @@ class _ChartHost(App): """Mounts one BrailleChart pinned to an exact cell size.""" - def __init__(self, width, height, glyph_mode, color_mode, palette) -> None: + def __init__(self, width, height, glyph_mode, color_mode, palette, fill) -> None: super().__init__() self._w = width self._h = height self._glyph_mode = glyph_mode + self._fill = fill # Pin the color tier so rendered styles are deterministic regardless of # the terminal the suite runs under (CI has no TERM, which otherwise # resolves to the "none" tier and drops all color). @@ -39,6 +40,7 @@ def compose(self) -> ComposeResult: glyph_mode=self._glyph_mode, color_mode=self._color_mode, palette=self._palette, + fill=self._fill, ) chart.styles.width = self._w chart.styles.height = self._h @@ -47,10 +49,16 @@ def compose(self) -> ComposeResult: def _render_chart( - data, width, height, glyph_mode="dots", color_mode="truecolor", palette="thermal" + data, + width, + height, + glyph_mode="dots", + color_mode="truecolor", + palette="thermal", + fill="up", ) -> Text: async def _run() -> Text: - app = _ChartHost(width, height, glyph_mode, color_mode, palette) + app = _ChartHost(width, height, glyph_mode, color_mode, palette, fill) async with app.run_test(size=(width + 8, height + 8)) as pilot: app.chart.data = data await pilot.pause() @@ -141,3 +149,41 @@ def test_palette_selects_a_different_gradient_for_the_same_value() -> None: assert thermal[0].startswith("rgb(") and viridis[0].startswith("rgb(") assert thermal[0] != viridis[0] + + +def test_down_fill_hangs_the_trace_from_the_top_edge() -> None: + # The lower half of each Network / Disk mirror is a `fill="down"` chart + # stacked under a normal one, so the seam between them reads as a shared + # zero axis. If the downward chart rendered bottom-up like every other + # sparkline, the mirror would silently become two identical traces with a + # gap in the middle — the whole feature inverted, and nothing else catches + # it. A partial reading must therefore occupy the TOP rows and leave the + # bottom blank, the exact opposite of the default direction. + rendered = _render_chart(data=[50.0], width=1, height=4, fill="down") + rows = rendered.plain.splitlines() + + assert rows[0][0] != _BRAILLE_BLANK + assert rows[1][0] != _BRAILLE_BLANK + assert rows[2][0] == _BRAILLE_BLANK + assert rows[3][0] == _BRAILLE_BLANK + + # Same reading in the default direction fills the bottom instead. + up_rows = _render_chart(data=[50.0], width=1, height=4).plain.splitlines() + assert up_rows[0][0] == _BRAILLE_BLANK + assert up_rows[3][0] != _BRAILLE_BLANK + + +def test_down_fill_block_mode_uses_upper_block_glyphs() -> None: + # Block mode has no full upper-fill ramp in Block Elements, so the downward + # half quantizes to ▀/█ rather than reaching for the Symbols for Legacy + # Computing quarter-blocks that many terminal fonts render as tofu. The + # trace must still hang from the top with a legible partial cap. + rows = _render_chart( + data=[80.0], width=1, height=4, glyph_mode="block", fill="down" + ).plain.splitlines() + + assert rows[0][0] == "█" + assert rows[1][0] == "█" + assert rows[2][0] == "█" + assert rows[3][0] == "▀" + assert set("".join(rows)) <= {"▀", "█", _BLOCK_BLANK} diff --git a/tests/test_dashboard_metrics.py b/tests/test_dashboard_metrics.py index 707904b..3fbb1da 100644 --- a/tests/test_dashboard_metrics.py +++ b/tests/test_dashboard_metrics.py @@ -180,10 +180,15 @@ async def _drive(snapshots, config=None): "bw_label": str(dash.query_one("#bw-label", Static).render()), "bw_label_display": dash.query_one("#bw-label", Static).display, "bw_chart_display": dash.query_one("#bw-chart", BrailleChart).display, - "net_label": str(dash.query_one("#net-label", Static).render()), - "net_label_display": dash.query_one("#net-label", Static).display, - "disk_label": str(dash.query_one("#disk-label", Static).render()), - "disk_label_display": dash.query_one("#disk-label", Static).display, + "net_rx_label": str(dash.query_one("#net-rx-label", Static).render()), + "net_tx_label": str(dash.query_one("#net-tx-label", Static).render()), + "net_section_display": dash.query_one("#section-net").display, + "net_rx_chart": list(dash.query_one("#net-rx-chart", BrailleChart).data), + "disk_read_label": str(dash.query_one("#disk-read-label", Static).render()), + "disk_write_label": str( + dash.query_one("#disk-write-label", Static).render() + ), + "disk_section_display": dash.query_one("#section-disk").display, "fan_label": str(dash.query_one("#fan-label", Static).render()), "fan_label_display": dash.query_one("#fan-label", Static).display, "gpu_label": str(dash.query_one("#gpu-label", Static).render()), @@ -257,38 +262,52 @@ def test_mem_bw_row_hidden_when_bandwidth_unavailable(): assert state["bw_chart_display"] is False -def test_net_disk_rows_show_rates_when_available(): - # Net/disk ride the same snapshot contract as Mem BW: byte rates surfaced - # as human-readable decimal throughput (↓/↑, R/W), rows visible only when - # the underlying readers reported usable counters. +def test_net_disk_sections_show_rates_and_scaled_charts_when_available(): + # Net/disk own titled sections with a chart per direction. Two samples: the + # first sets the rolling peak (100 MB/s rx), the second drops to a quarter + # of it. The labels must carry the live rate + session max in decimal + # throughput, and the charts must carry *percents* against the shared + # peak-based denominator (peak x1.25) — 100 MB/s -> 80%, 25 MB/s -> 20%. + # A chart wired to the native-unit deque instead of the percent deque would + # ship 1e8 here and render permanently full-scale; only a two-sample + # assertion catches that. + def snap(net_rx, net_tx, disk_r, disk_w): + return _snapshot( + 120.0, + True, + net_rx_bps=net_rx, + net_tx_bps=net_tx, + net_available=True, + disk_read_bps=disk_r, + disk_write_bps=disk_w, + disk_available=True, + ) + state = asyncio.run( _drive( [ - _snapshot( - 120.0, - True, - net_rx_bps=1_500_000, # 1.5 MB/s - net_tx_bps=750_000, # 750 KB/s - net_available=True, - disk_read_bps=25_000_000, # 25 MB/s - disk_write_bps=500_000, # 500 KB/s - disk_available=True, - ) + snap(100_000_000, 750_000, 400_000_000, 500_000), + snap(25_000_000, 750_000, 100_000_000, 500_000), ] ) ) - assert state["net_label_display"] is True - assert "Net ↓ 1.5 MB/s ↑ 750.0 KB/s" in state["net_label"] - assert state["disk_label_display"] is True - assert "Disk R 25.0 MB/s W 500.0 KB/s" in state["disk_label"] - - -def test_net_disk_rows_hidden_when_unavailable(): - # No usable counters: hide the rows rather than show a phantom 0 B/s, - # the same hide-row contract the Mem BW / Fan rows honour. + assert state["net_section_display"] is True + assert "↓ In 25.0 MB/s" in state["net_rx_label"] + assert "max 100.0 MB/s" in state["net_rx_label"] + assert "↑ Out 750.0 KB/s" in state["net_tx_label"] + assert state["disk_section_display"] is True + assert "↓ Read 100.0 MB/s" in state["disk_read_label"] + assert "max 400.0 MB/s" in state["disk_read_label"] + assert "↑ Write 500.0 KB/s" in state["disk_write_label"] + assert list(state["net_rx_chart"])[-2:] == [80, 20] + + +def test_net_disk_sections_hidden_when_unavailable(): + # No usable counters: hide the whole section rather than show a phantom + # 0 B/s box, the same hide-on-unavailable contract Mem BW / Fan honour. state = asyncio.run(_drive([_snapshot(120.0, True)])) - assert state["net_label_display"] is False - assert state["disk_label_display"] is False + assert state["net_section_display"] is False + assert state["disk_section_display"] is False def test_renderer_tiler_row_shows_driver_split_when_available(): diff --git a/uv.lock b/uv.lock index bb141c2..a84e74c 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,7 @@ resolution-markers = [ [[package]] name = "actop" -version = "1.8.0" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "rich" },