TUI/WebUI v5: the data-type render modes B, 0 and T (2.X-c, part 2)

Three more of 2.X-c, on both surfaces. Unlike part 1's `b`/`6`/`F`, v5
rendered only ONE mode of each of these -- but the data for the second was
already collected, so each is a renderer change, not a model change.

`B` disk I/O byte/s vs IOPS. `read_count`/`write_count` were already
collected and flagged `internal` (out of the generic renderer's default
columns, never out of the payload). They now carry v4's own `IOR/s`/`IOW/s`
short names, so swapping the field pair swaps the header with it on both
surfaces -- the labels come from the schema. IOPS need their own formatter:
counts scale by 1000 and carry no unit, where bytes scale by 1024 and carry a
`B`. `_format_count_rate` and `formatIops` are that pair, deliberately not
`formatCount` (1024-based).

`0` load average vs Irix percentage (issue #1554). `cpucore` was already
collected and `internal`. v4 reads the count from the `core` plugin's
`log_core()`; v5 reads the field the load payload already carries -- same
number, no cross-plugin reach. Both surfaces keep v4's guard: an absent or
zero core count falls back to the plain float rather than dividing by zero.
The key changes what the cell DISPLAYS, never which level colours it --
per-core normalisation is already implicit in the thresholds
(`normalize_by: cpucore`), and a test pins that.

`T` network Rx/Tx apart vs combined. A divergence in route, not in value: v4
renders a `bytes_all` field its model computes; v5's schema has no such
field, so both renderers sum the two rates they already carry. The sum of two
rates over one interval IS the combined rate. The combined cell takes no
threshold colour -- the two fields own their levels and a sum belongs to
neither, which is why v4 paints its own combined cell plain.

The diskio render fixture gained `read_count`/`write_count` on every row and
their short names. It predated the fields, so under `B` every row was dropped
for a missing value and the header fell back to the raw field names. A
fixture that does not carry what the server sends tests nothing.

Verified against real plugins: `B` turns `R/s 0B` into `IOR/s 0`, `0` turns
`1 min 0.46` into `1 min 11.6%`, and `T` turns `642b 642b` into `1.3Kb`.

2.X-c now stands at six of nine. The remaining three are blocked on data, not
on keys: `U` needs the raw counter `_transform_gauge` replaces with its rate,
`L` needs `read_time`/`write_time` collected at all, and `S` needs a v5
history store. Section 10 records each with its file reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PW4fwSR6ceSmETznbLExNK
This commit is contained in:
Claude committed 2026-09-22 15:33:44 +00:00
1 parent 22984a0d80
commit c528497d9c
19 files changed
+486 -46

No files matched your search

@@ -1236,8 +1236,42 @@ curses surface — as its own owned group. No implementation in parity wave 1
cannot avoid** — the TUI shows the path prefix only when
`os.path.isdir(path)` holds (`render_curses_v5.py:329`), and a browser has
no filesystem, so it shows the prefix whenever `cmdline[0]` carried one.
- **The 9 remaining data-type toggles** (`b`/`B` byte/bit, `%`, `S`, …), plus
`F` (fs free space), moved here from the show/hide line above.
- **The data-type toggles** (2.X-c, 2026-09-22). **Six of the nine shipped**,
on both surfaces, plus `F` (fs free space) which moved here from the
show/hide line above. Three are blocked, and NONE of the three is a hotkey
problem — the key is one table entry either way; what is missing is the
second render mode's DATA.
Shipped because v5 already rendered both modes, so the key was the whole
feature: `b` (network bit/s ↔ byte/s), `6` (GPU per-card ↔ mean), `F`
(filesystem used ↔ free).
Shipped after writing the second mode, the data being already collected:
`B` (disk I/O byte/s ↔ IOPS — `read_count`/`write_count` were collected and
`internal`, and now carry the `IOR/s`/`IOW/s` short names v4 uses), `0`
(load average ↔ Irix percentage — `cpucore` was collected and `internal`),
`T` (network Rx/Tx apart ↔ combined).
`T` carries a deliberate divergence in route, not in value: v4 renders a
`bytes_all` field its model computes; v5's schema has no such field, so both
renderers sum the two rates they already have. The sum of two rates over one
interval is the combined rate.
**Blocked, each needing model or infrastructure work first:**
- `U` (network live ↔ cumulative) — `_transform_gauge`
(`plugins/plugin/base_v5.py`) REPLACES a counter with its rate and keeps
the raw value only in `_raw_previous`. Exposing it is a field-contract
change to the REST payload, not a renderer change.
- `L` (disk I/O byte/s ↔ latency) — `read_time`/`write_time` are not
collected at all, explicitly deferred (`diskio/model_v5.py`).
- `S` (quicklook bar ↔ sparkline) — no v5 history store
(`quicklook/model_v5.py`).
`F` needed a shape neither group had used: `[fs] free_space` is plugin
CONFIG and reaches the renderer as payload metadata, so the TUI's ViewState
field is tri-state (`None` = follow the payload) and the browser seeds it
from the fs payload rather than from `serverArgs`, which carries only the
CLI flag and reads `false` for a config-set `true`.
- **`F5` / `Ctrl-R`** forced refresh and the sort-navigation arrow keys.
The `ViewState` mechanism this group builds on already exists
+9
View File
@@ -142,6 +142,9 @@ class ViewState:
# flipped by their keys.
byte: bool = False
meangpu: bool = False
diskio_iops: bool = False
load_irix: bool = False
network_sum: bool = False
# Tri-state, unlike the two above: `[fs] free_space` lives in the fs
# plugin's CONFIG and reaches the renderer as payload metadata, not as a
# constructor argument the TUI could seed from. `None` therefore means
@@ -236,6 +239,9 @@ class TuiV5(threading.Thread):
# through the per-cycle `view` dict.
"b": {"switch": "byte", "group": "TOGGLE VIEW", "desc": "Network I/O in bit/s or byte/s"},
"6": {"switch": "meangpu", "group": "TOGGLE VIEW", "desc": "GPU: per-card or mean"},
"B": {"switch": "diskio_iops", "group": "TOGGLE VIEW", "desc": "Disk I/O in byte/s or IOPS"},
"0": {"switch": "load_irix", "group": "TOGGLE VIEW", "desc": "Load average or Irix percentage"},
"T": {"switch": "network_sum", "group": "TOGGLE VIEW", "desc": "Network Rx/Tx apart or combined"},
# Tri-state, so it cannot be a plain `switch`: `None` means "follow
# `[fs] free_space`", which is why it has its own verb.
"F": {"action": "fs_free_space", "group": "TOGGLE VIEW", "desc": "Filesystem: used or free space"},
@@ -939,6 +945,9 @@ class TuiV5(threading.Thread):
view["fahrenheit"] = self._fahrenheit
view["hide_public_info"] = self._hide_public_info
view["byte"] = self._view.byte
view["diskio_iops"] = self._view.diskio_iops
view["load_irix"] = self._view.load_irix
view["network_sum"] = self._view.network_sum
# Only published when the viewer has pressed `F`; absent means the fs
# renderer keeps reading its payload metadata.
if self._view.fs_free_space is not None:
+26 -5
View File
@@ -4,7 +4,7 @@
<!-- The TUI's header row: block title, then the schema's labels. -->
<tr>
<th class="gl-header">{{ TITLE }}</th>
<th v-for="field in RATE_FIELDS" :key="field" class="gl-header gl-num">
<th v-for="field in rateFields" :key="field" class="gl-header gl-num">
{{ labelFor(labels, field) }}
</th>
</tr>
@@ -16,8 +16,8 @@
<td>
<span class="gl-name gl-truncate gl-truncate-start" :title="nameOf(item)"><bdi>{{ nameOf(item) }}</bdi></span>
</td>
<td v-for="field in RATE_FIELDS" :key="field" class="gl-num">
<span :class="cellClassFor(payload, item, field)">{{ formatBytes(item[field]) }}</span>
<td v-for="field in rateFields" :key="field" class="gl-num">
<span :class="cellClassFor(payload, item, field)">{{ formatCell(item[field]) }}</span>
</td>
</tr>
</tbody>
@@ -26,7 +26,7 @@
</template>
<script>
import { formatBytes } from "./format.js";
import { formatBytes, formatIops } from "./format.js";
import { labelFor } from "./labels.js";
import { cellClassFor } from "./columns.js";
import { byText, displayName } from "./rows.js";
@@ -34,6 +34,10 @@ import CollectionBlock from "./CollectionBlock.vue";
import { PLUGIN_PROPS } from "./plugin_props.js";
const RATE_FIELDS = ["read_bytes", "write_bytes"];
// The `B` key (v4 `_handle_diskio_iops`): operations per second instead of
// byte rates. Same two columns, a different pair of fields -- and the labels
// come from the schema, so swapping the pair swaps the header too.
const IOPS_FIELDS = ["read_count", "write_count"];
const TITLE = "DISK I/O";
@@ -46,18 +50,35 @@ export default {
// A computed, not data(): data() would hand the template a deeply
// reactive Proxy of the array.
RATE_FIELDS: () => RATE_FIELDS,
// The `B` key, through AppShell's `effectiveArgs`.
iops() {
return !!this.serverArgs.diskio_iops;
},
rateFields() {
return this.iops ? IOPS_FIELDS : RATE_FIELDS;
},
// Mirrors diskio/render_curses_v5.py:109-122: sorted by raw disk_name;
// skip a row hide_zero still hides, a disk with no rate yet (cycle 1),
// and a nameless one. Byte rates without "/s", the header carries it.
rows() {
return (this.payload?.data || [])
.filter((item) => item.hidden !== true && item.read_bytes != null && item.write_bytes != null && item.disk_name)
.filter(
(item) =>
item.hidden !== true &&
this.rateFields.every((field) => item[field] != null) &&
item.disk_name
)
.sort(byText("disk_name"));
},
},
methods: {
labelFor,
cellClassFor,
// Byte rates and operation counts scale differently (1024 vs 1000) and
// only one carries a unit -- see formatIops in format.js.
formatCell(value) {
return this.iops ? formatIops(value) : formatBytes(value);
},
formatBytes,
nameOf(item) {
return displayName(item, "disk_name");
+9 -1
View File
@@ -45,9 +45,17 @@ export default {
return typeof cores === "number" && cores > 0 ? `${Math.trunc(cores)}core` : "";
},
rows() {
// The `0` key (v4 `args.disable_irix`, issue #1554): each average
// divided by the core count and shown as a percentage. Mirrors
// load/render_curses_v5.py::_load_value_cell, including its guard --
// an absent or zero `cpucore` falls back to the plain float rather
// than dividing by zero.
const cores = this.payload?.cpucore;
const irix = !!this.serverArgs.load_irix && typeof cores === "number" && cores > 0;
return FIELDS.map((field) => {
const value = this.payload?.[field];
return { field, value: typeof value === "number" ? value.toFixed(2) : "-" };
if (typeof value !== "number") return { field, value: "-" };
return { field, value: irix ? `${((value / cores) * 100).toFixed(1)}%` : value.toFixed(2) };
});
},
},
+25 -2
View File
@@ -6,7 +6,8 @@
shows no line, as the TUI paints its header. -->
<tr>
<th class="gl-header">{{ TITLE }}</th>
<th v-for="field in RATE_FIELDS" :key="field" class="gl-header gl-num">
<th v-if="combined" class="gl-header gl-num" colspan="2">{{ combinedLabel }}</th>
<th v-for="field in combined ? [] : RATE_FIELDS" :key="field" class="gl-header gl-num">
{{ labelFor(labels, field) }}
</th>
</tr>
@@ -22,7 +23,14 @@
</td>
<!-- The tier goes on the <span>, not the <td>: a prominent badge's
background would otherwise fill the whole cell. -->
<td v-for="field in RATE_FIELDS" :key="field" class="gl-num">
<!-- `T` (v4 `network_sum`): one combined column. No tier class --
the two fields carry their own levels and a sum belongs to
neither, which is why the TUI paints its combined cell plain
too. -->
<td v-if="combined" class="gl-num" colspan="2">
<span>{{ formatNetworkRate(rxPlusTx(item), !!serverArgs.byte) }}</span>
</td>
<td v-for="field in combined ? [] : RATE_FIELDS" :key="field" class="gl-num">
<span :class="cellClassFor(payload, item, field)">{{
formatNetworkRate(item[field], !!serverArgs.byte)
}}</span>
@@ -55,6 +63,15 @@ export default {
// A computed, not data(): data() would hand the template a deeply
// reactive Proxy of the array.
RATE_FIELDS: () => RATE_FIELDS,
// The `T` key, through AppShell's `effectiveArgs`.
combined() {
return !!this.serverArgs.network_sum;
},
// v4's own label for the mode (`network/__init__.py:246-254`): the
// "/s" is dropped under --byte there too.
combinedLabel() {
return this.serverArgs.byte ? "Rx+Tx" : "Rx+Tx/s";
},
// Mirrors network/render_curses_v5.py:129-142: skip a down interface
// (v4 #765), one hide_zero still hides, and one with no rate yet (cycle
// 1). Payload order -- the TUI does not sort this block.
@@ -66,6 +83,12 @@ export default {
},
},
methods: {
// v5 has no `bytes_all` field (v4's model computes one); summing the
// two rates the payload already carries gives the same number over the
// same interval. Mirrors network/render_curses_v5.py.
rxPlusTx(item) {
return Number(item.bytes_recv) + Number(item.bytes_sent);
},
labelFor,
cellClassFor,
formatNetworkRate,
+17
View File
@@ -104,6 +104,23 @@ export function formatAutoUnit(value) {
return toFixedHalfEven(value, fallbackDecimals);
}
// Mirrors diskio/render_curses_v5.py::_format_count_rate(), the `B` key's
// IOPS mode. Counts, not bytes: 1000 is the step (not 1024) and there is no
// unit suffix. Deliberately NOT formatCount() below, which is 1024-based --
// the two surfaces must print the same string for the same number.
export function formatIops(value) {
if (!isNumber(value)) return MISSING;
const abs = Math.abs(value);
for (const [symbol, threshold] of [
["G", 1e9],
["M", 1e6],
["K", 1e3]
]) {
if (abs >= threshold) return `${(value / threshold).toFixed(1)}${symbol}`;
}
return String(Math.trunc(value));
}
// Mirrors network/render_curses_v5.py::_format_rate(). Bits by default --
// bytes x 8, with a `b` on every magnitude ("800b", "8.0Mb") -- and the
// plain byte count with no `b` suffix under --byte ("100", "1.0M").
+4 -1
View File
@@ -64,7 +64,10 @@ export const VIEW_KEYS = {
j: { desc: "Threads / programs view", flag: "programs" },
b: { desc: "Network I/O in bit/s or byte/s", flag: "byte" },
"6": { desc: "GPU: per-card or mean", flag: "meangpu" },
F: { desc: "Filesystem: used or free space", flag: "fs_free_space" }
F: { desc: "Filesystem: used or free space", flag: "fs_free_space" },
B: { desc: "Disk I/O in byte/s or IOPS", flag: "diskio_iops" },
"0": { desc: "Load average or Irix percentage", flag: "load_irix" },
T: { desc: "Network Rx/Tx apart or combined", flag: "network_sum" }
};
/** The view flag `key` flips, or null when `key` is not a TOGGLE VIEW key. */
Binary file not shown.
+11 -5
View File
@@ -17,9 +17,9 @@ V5 scope (G4-diskio):
but **no default thresholds** — sustained disk traffic is host-
specific, alerts only fire when the user sets per-disk or per-field
keys in ``[diskio]`` (e.g. ``read_bytes_warning=50_000_000``).
- ``read_count`` / ``write_count`` are kept exportable for IOPS-style
consumers but flagged ``internal=True`` so the generic renderer
skips them.
- ``read_count`` / ``write_count`` are flagged ``internal=True`` so the
generic renderer skips them; the ``B`` hotkey's IOPS mode renders them
explicitly, and exporters have always had them.
- ``read_time``/``write_time`` and the derived ``read_latency`` /
``write_latency`` of v4 are not ported — deferred to a later phase
with the ``--diskio-latency`` mode.
@@ -61,15 +61,21 @@ class PluginModel(GlancesPluginBase[list]):
"description": "Read operations per second (rate of psutil read_count counter).",
"unit": "number",
"rate": True,
# Useful for IOPS-style export downstream but not rendered in
# the default TUI (which shows byte rates only — v4 parity).
# `internal` keeps it out of the GENERIC renderer's default
# columns (which show byte rates — v4 parity); the `B` hotkey's
# IOPS mode renders it explicitly, and exporters have always had
# it. `short_name` is v4's header for that mode
# (`diskio/__init__.py:242`).
"internal": True,
"short_name": "IOR/s",
},
"write_count": {
"description": "Write operations per second (rate of psutil write_count counter).",
"unit": "number",
"rate": True,
# See `read_count` above.
"internal": True,
"short_name": "IOW/s",
},
"read_bytes": {
"description": "Bytes read per second (rate of psutil read_bytes counter).",
+35 -7
View File
@@ -63,8 +63,29 @@ def _format_byte_rate(bytes_per_sec: Any) -> str:
return f"{int(bytes_value)}B"
def _rate_cell(value: Any, level_entry: dict[str, Any]) -> Cell:
text = _format_byte_rate(value).rjust(_RATE_COL_WIDTH)
def _format_count_rate(ops_per_sec: Any) -> str:
"""Operations/s → human-readable, K/M/G scaled, no unit suffix.
The `B` hotkey's IOPS mode. Same shape as `_format_byte_rate` above but
decimal-scaled and unitless: these are counts, not bytes, so 1000 is the
step and there is no `B` to append (v4 `auto_unit`, no unit argument,
`diskio/__init__.py:272`).
"""
try:
value = float(ops_per_sec)
except (TypeError, ValueError):
return "-"
for symbol, threshold in (("G", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)):
if abs(value) >= threshold:
return f"{value / threshold:.1f}{symbol}"
return f"{int(value)}"
def _rate_cell(value: Any, level_entry: dict[str, Any], iops: bool = False) -> Cell:
# IOPS are a plain count, so they take the generic auto-unit rather than
# the byte formatter -- v4 `auto_unit(read_count_rate_per_sec)` with no
# unit suffix (`diskio/__init__.py:272`).
text = (_format_count_rate(value) if iops else _format_byte_rate(value)).rjust(_RATE_COL_WIDTH)
level = level_entry.get("level") if isinstance(level_entry, dict) else None
role = _LEVEL_TO_ROLE.get(level, ColorRole.DEFAULT)
prominent = bool(level_entry.get("prominent")) if isinstance(level_entry, dict) else False
@@ -77,8 +98,15 @@ def _format_disk_name(name: str) -> str:
return name.ljust(_NAME_MAX_WIDTH)
def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> list[Row]:
def render(
payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]], view: dict[str, Any] | None = None
) -> list[Row]:
"""Render the diskio plugin's TUI block — mirrors v4 ``diskio.msg_curse``."""
# `B` (v4 `_handle_diskio_iops`): operations per second instead of byte
# rates. Same two columns, different pair of fields -- the labels come
# from the schema, so swapping the pair swaps the header too.
iops = bool((view or {}).get("diskio_iops"))
read_key, write_key = ("read_count", "write_count") if iops else ("read_bytes", "write_bytes")
# The first header cell is the TUI block title, not a field label -- it
# stays a literal. The rate columns read their labels from the schema
# (single source of truth, shared with the WebUI), as network's do.
@@ -91,7 +119,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l
color=ColorRole.HEADER,
bold=True,
)
for key in ("read_bytes", "write_bytes")
for key in (read_key, write_key)
),
]
)
@@ -114,7 +142,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l
if item.get("hidden") is True:
continue
# Skip disks with no rate yet — cycle 1 sets read_bytes/write_bytes to None.
if item.get("read_bytes") is None or item.get("write_bytes") is None:
if item.get(read_key) is None or item.get(write_key) is None:
continue
name = str(item.get("disk_name") or "")
@@ -132,8 +160,8 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l
Row(
cells=[
Cell(text=_format_disk_name(display_name)),
_rate_cell(item.get("read_bytes"), disk_levels.get("read_bytes", {})),
_rate_cell(item.get("write_bytes"), disk_levels.get("write_bytes", {})),
_rate_cell(item.get(read_key), disk_levels.get(read_key, {}), iops=iops),
_rate_cell(item.get(write_key), disk_levels.get(write_key, {}), iops=iops),
],
item_start=True,
)
+37 -9
View File
@@ -27,12 +27,15 @@ Reference layout:
- ``min15`` decoration from ``_levels.min15`` (v4 = primary alert path).
- ``min5`` decoration from ``_levels.min5``.
- ``min1`` plain (no alert in v4).
- ``cpucore`` is declared ``internal: True`` in the schema: never
rendered as its own row — only used as the ``Ncore`` suffix on line 1.
- ``cpucore`` is declared ``internal: True`` in the schema: never rendered
as its own row — it supplies the ``Ncore`` suffix on line 1, and the
divisor for Irix mode below.
Irix mode (v4 ``args.disable_irix``) is not yet plumbed through v5 —
load values are shown as plain floats; per-core normalisation is
implicit in the threshold computation via ``normalize_by: cpucore``.
Irix mode (v4 ``args.disable_irix``, the ``0`` hotkey, issue #1554) divides
each average by ``cpucore`` and shows a percentage instead of a plain float.
Off by default, as in v4. Note that per-core normalisation is ALREADY implicit
in the threshold computation (``normalize_by: cpucore``) — this key changes
only what the cell displays, never which level it is coloured with.
"""
from __future__ import annotations
@@ -54,10 +57,32 @@ def _format_load(value: Any) -> str:
return " -"
def _load_value_cell(payload: dict[str, Any], key: str) -> Cell:
"""Return a Cell for a single load average, coloured per `_levels`."""
def _load_value_cell(payload: dict[str, Any], key: str, irix: bool = False) -> Cell:
"""Return a Cell for a single load average, coloured per `_levels`.
`irix` is the `0` key (v4 `args.disable_irix`, issue #1554): the raw load
average is divided by the core count and shown as a percentage. v4 reads
the count from the `core` plugin's `log_core()`
(`load/__init__.py:168-171`); v5 reads the `cpucore` field the load
payload already carries, which is the same number without the
cross-plugin reach.
"""
if key not in payload or payload.get(key) is None:
return Cell(text=" -")
if irix:
cores = payload.get("cpucore")
# v4 guards on `log_core() != 0`; an absent or zero count falls back
# to the default mode rather than dividing by zero.
if isinstance(cores, (int, float)) and cores > 0:
text = f"{payload[key] / cores * 100:>5.1f}%"
levels = payload.get("_levels", {}) if isinstance(payload, dict) else {}
entry = levels.get(key, {}) if isinstance(levels, dict) else {}
level = entry.get("level") if isinstance(entry, dict) else None
return Cell(
text=text,
color=_LEVEL_TO_ROLE.get(level, ColorRole.DEFAULT),
prominent=bool(entry.get("prominent")) if isinstance(entry, dict) else False,
)
text = _format_load(payload[key])
levels = payload.get("_levels", {}) if isinstance(payload, dict) else {}
entry = levels.get(key, {}) if isinstance(levels, dict) else {}
@@ -67,8 +92,11 @@ def _load_value_cell(payload: dict[str, Any], key: str) -> Cell:
return Cell(text=text, color=role, prominent=prominent)
def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> list[Row]:
def render(
payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]], view: dict[str, Any] | None = None
) -> list[Row]:
"""Render the load plugin's TUI block — mirrors v4 ``load.msg_curse``."""
irix = bool((view or {}).get("load_irix"))
if not payload:
return [Row(cells=[Cell(text="LOAD", color=ColorRole.HEADER, bold=True)])]
@@ -95,7 +123,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l
for key in ("min1", "min5", "min15"):
label = field_label(fields_desc.get(key, {}), key, prefer_short=True)
label_cell = Cell(text=label.ljust(_LOAD_LABEL_WIDTH))
value_cell = _load_value_cell(payload, key)
value_cell = _load_value_cell(payload, key, irix=irix)
rows.append(Row(cells=[label_cell, value_cell]))
return rows
+36 -5
View File
@@ -102,6 +102,15 @@ def render(
) -> list[Row]:
"""Render the network plugin's TUI block — mirrors v4 ``network.msg_curse``."""
byte = bool((view or {}).get("byte"))
# `T` (v4 `network_sum`): one combined column instead of two.
#
# DIVERGENCE, in the route not the number. v4 renders a dedicated
# `bytes_all` / `bytes_all_rate_per_sec` field its model computes
# (`network/__init__.py:276,294`); v5's schema has no such field, so the
# renderer sums the two rates it already has. Same value, one fewer field
# to keep in step -- and the sum of two rates over the same interval IS
# the combined rate.
combined = bool((view or {}).get("network_sum"))
# The first header cell is the TUI block title, not a field label — it
# stays a literal. The value columns read their labels from the schema
# (single source of truth, shared with the WebUI).
@@ -118,6 +127,20 @@ def render(
),
]
)
if combined:
# v4's label for the mode (`network/__init__.py:254`), widened to the
# two columns it replaces so the block keeps its width and the right
# edge stays where the other left-column blocks put theirs.
header_row = Row(
cells=[
Cell(text="NETWORK".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True),
Cell(
text=("Rx+Tx" if byte else "Rx+Tx/s").rjust(_RATE_COL_WIDTH * 2 + 1),
color=ColorRole.HEADER,
bold=True,
),
]
)
rows: list[Row] = [header_row]
if not isinstance(payload, dict):
@@ -154,13 +177,21 @@ def render(
# keyed by the raw `name`.
display_name = str(item.get("alias") or name)
if combined:
total = float(item["bytes_recv"]) + float(item["bytes_sent"])
# No threshold decoration: the two fields carry their own levels
# and a sum belongs to neither. v4 paints its `ax` cell plain for
# the same reason (`network/__init__.py:294-295`).
value_cells = [Cell(text=_format_rate(total, byte).rjust(_RATE_COL_WIDTH * 2 + 1))]
else:
value_cells = [
_rate_cell(item.get("bytes_recv"), if_levels.get("bytes_recv", {}), byte),
_rate_cell(item.get("bytes_sent"), if_levels.get("bytes_sent", {}), byte),
]
rows.append(
Row(
cells=[
Cell(text=_format_if_name(display_name)),
_rate_cell(item.get("bytes_recv"), if_levels.get("bytes_recv", {}), byte),
_rate_cell(item.get("bytes_sent"), if_levels.get("bytes_sent", {}), byte),
],
cells=[Cell(text=_format_if_name(display_name)), *value_cells],
item_start=True,
)
)
+15 -5
View File
@@ -196,7 +196,13 @@ const INFO_FIXTURES = {
cloud: { id: {}, platform: {}, name: {}, type: {}, region: {} },
// short_names copied from glances/plugins/diskio/model_v5.py and
// glances/plugins/fs/model_v5.py (G9-6 Task 3).
diskio: { disk_name: {}, read_bytes: { short_name: "R/s" }, write_bytes: { short_name: "W/s" } },
diskio: {
disk_name: {},
read_bytes: { short_name: "R/s" },
write_bytes: { short_name: "W/s" },
read_count: { short_name: "IOR/s" },
write_count: { short_name: "IOW/s" },
},
fs: { mnt_point: {}, size: { short_name: "Total" }, used: { short_name: "Used" }, free: { short_name: "Free" }, percent: {} },
// short_name copied from glances/plugins/wifi/model_v5.py (G9-6 Task 3);
// sensors declares none -- its value column has no header in the TUI.
@@ -333,11 +339,15 @@ const NETWORK_ROWS = {
// exact 1.25K tie ("1.2K"). `sdb`'s read rate carries a warning.
const DISKIO_FIXTURE = {
_key: "disk_name",
// `read_count` / `write_count` ride along on every row: the server has
// always published them (`internal: true` keeps them out of the DEFAULT
// columns, not out of the payload), and the `B` key renders them instead
// of the byte rates. A row missing them would be dropped in that mode.
data: [
{ disk_name: "sdb", alias: "Backup", read_bytes: 1536, write_bytes: 0, hidden: false },
{ disk_name: "loop0", read_bytes: 0, write_bytes: 0, hidden: true },
{ disk_name: "sda", read_bytes: null, write_bytes: null, hidden: false },
{ disk_name: "nvme0n1", read_bytes: 855.6, write_bytes: 1280, hidden: false },
{ disk_name: "sdb", alias: "Backup", read_bytes: 1536, write_bytes: 0, read_count: 12, write_count: 0, hidden: false },
{ disk_name: "loop0", read_bytes: 0, write_bytes: 0, read_count: 0, write_count: 0, hidden: true },
{ disk_name: "sda", read_bytes: null, write_bytes: null, read_count: null, write_count: null, hidden: false },
{ disk_name: "nvme0n1", read_bytes: 855.6, write_bytes: 1280, read_count: 2500, write_count: 7.4, hidden: false },
],
_levels: { sdb: { read_bytes: { level: "warning", prominent: false } } },
};
+30 -1
View File
@@ -1,6 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatBytes, formatRate, formatPercent, formatCount, toFahrenheit, formatSeconds, toFixedHalfEven, formatNetworkRate, formatFixed0, formatAutoUnit, formatAutoHz, formatProcessBytes, formatUsername } from "../../glances/outputs/static/js/v5/format.js";
import {
formatAutoHz,
formatAutoUnit,
formatBytes,
formatCount,
formatFixed0,
formatIops,
formatNetworkRate,
formatPercent,
formatProcessBytes,
formatRate,
formatSeconds,
formatUsername,
toFahrenheit,
toFixedHalfEven
} from "../../glances/outputs/static/js/v5/format.js";
test("formatBytes uses binary units", () => {
assert.equal(formatBytes(0), "0B");
@@ -261,3 +276,17 @@ test('formatAutoHz returns "?" for whatever it cannot parse', () => {
assert.equal(formatAutoHz(""), "?");
assert.equal(formatAutoHz(NaN), "?");
});
test("formatIops mirrors the TUI's _format_count_rate: 1000-step, unitless", () => {
// Deliberately NOT formatCount's 1024 step -- the two surfaces must print
// the same string for the same number.
assert.equal(formatIops(0), "0");
assert.equal(formatIops(7.4), "7");
assert.equal(formatIops(999), "999");
assert.equal(formatIops(1000), "1.0K");
assert.equal(formatIops(2500), "2.5K");
assert.equal(formatIops(1e6), "1.0M");
assert.equal(formatIops(1e9), "1.0G");
assert.equal(formatIops(null), "-");
assert.equal(formatIops(undefined), "-");
});
+13
View File
@@ -3339,3 +3339,16 @@ def test_the_fs_renderer_prefers_the_view_override_over_the_payload():
assert "used" in used_header and "free" not in used_header, used_header
assert "free" in free_header and "used" not in free_header, free_header
@pytest.mark.parametrize(("key", "attr"), [("B", "diskio_iops"), ("0", "load_irix"), ("T", "network_sum")])
def test_a_render_mode_key_flips_its_view_flag(key, attr, fake_store, fake_alerts, fake_config):
"""`B`, `0` and `T` do not remove a block either: they change which fields
a renderer draws. Unlike `b`/`6` the second mode had to be written — v5
rendered only one of each — but the data was already collected."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
assert tui._build_view(120)[attr] is False
assert tui._handle_key(ord(key)) == "changed"
assert tui._build_view(120)[attr] is True
@@ -233,3 +233,42 @@ def test_render_falls_back_to_disk_name_when_no_alias(diskio_fields):
}
rows = render(payload, diskio_fields)
assert "sda" in rows[1].cells[0].text
def test_iops_mode_swaps_both_the_columns_and_the_header():
"""`B` (v4 `_handle_diskio_iops`). The labels come from the schema, so
swapping the field pair swaps the header with it."""
from glances.plugins.diskio.render_curses_v5 import render
payload = {
"data": [
{"disk_name": "sda", "read_bytes": 2048.0, "write_bytes": 0.0, "read_count": 2500.0, "write_count": 7.4}
],
"_levels": {},
}
fields = {
"read_bytes": {"short_name": "R/s"},
"write_bytes": {"short_name": "W/s"},
"read_count": {"short_name": "IOR/s"},
"write_count": {"short_name": "IOW/s"},
}
default_rows = render(payload, fields)
iops_rows = render(payload, fields, view={"diskio_iops": True})
assert [c.text.strip() for c in default_rows[0].cells][1:] == ["R/s", "W/s"]
assert [c.text.strip() for c in iops_rows[0].cells][1:] == ["IOR/s", "IOW/s"]
# Byte rates take the 1024 scale and a `B`; counts take 1000 and no unit.
assert [c.text.strip() for c in default_rows[1].cells][1:] == ["2.0K", "0B"]
assert [c.text.strip() for c in iops_rows[1].cells][1:] == ["2.5K", "7"]
def test_iops_mode_skips_a_disk_with_no_count_yet():
"""Cycle 1 leaves every rate field None, counts included."""
from glances.plugins.diskio.render_curses_v5 import render
payload = {
"data": [{"disk_name": "sda", "read_bytes": 1.0, "write_bytes": 1.0, "read_count": None, "write_count": None}],
"_levels": {},
}
assert len(render(payload, {}, view={"diskio_iops": True})) == 1, "header only"
@@ -156,3 +156,48 @@ def test_render_total_line_width_matches_across_rows(load_payload, load_fields):
rows = render(load_payload, load_fields)
totals = {sum(len(c.text) for c in r.cells) + max(0, len(r.cells) - 1) for r in rows}
assert len(totals) == 1, f"line widths differ: {totals}"
def test_irix_mode_shows_percentages_of_the_core_count():
"""`0` (v4 `args.disable_irix`, issue #1554): each average divided by
`cpucore`, as a percentage."""
from glances.plugins.load.render_curses_v5 import render
payload = {"min1": 2.0, "min5": 1.0, "min15": 0.5, "cpucore": 4, "_levels": {}}
fields = {"min1": {"short_name": "1 min"}, "min5": {"short_name": "5 min"}, "min15": {"short_name": "15 min"}}
plain = [r.cells[1].text.strip() for r in render(payload, fields)[1:]]
irix = [r.cells[1].text.strip() for r in render(payload, fields, view={"load_irix": True})[1:]]
assert plain == ["2.00", "1.00", "0.50"]
assert irix == ["50.0%", "25.0%", "12.5%"]
def test_irix_mode_falls_back_when_the_core_count_is_unusable():
"""v4 guards on `log_core() != 0`; an absent or zero count must not divide
by zero, and must not print a nonsense percentage either."""
from glances.plugins.load.render_curses_v5 import render
for cores in (0, None):
payload = {"min1": 2.0, "min5": 1.0, "min15": 0.5, "cpucore": cores, "_levels": {}}
values = [r.cells[1].text.strip() for r in render(payload, {}, view={"load_irix": True})[1:]]
assert values == ["2.00", "1.00", "0.50"], cores
def test_irix_mode_keeps_the_threshold_colour():
"""The key changes what the cell DISPLAYS, never which level it is
coloured with — per-core normalisation is already implicit in the
threshold computation (`normalize_by: cpucore`)."""
from glances.outputs.curses_renderer_v5 import ColorRole
from glances.plugins.load.render_curses_v5 import render
payload = {
"min1": 2.0,
"min5": 1.0,
"min15": 8.0,
"cpucore": 4,
"_levels": {"min15": {"level": "critical", "prominent": True}},
}
row = render(payload, {}, view={"load_irix": True})[3]
assert row.cells[1].color == ColorRole.CRITICAL
assert row.cells[1].prominent is True
@@ -396,3 +396,51 @@ def test_rate_between_1000_and_1024_units_fits_the_column(scaled, symbol, factor
text = _format_rate(scaled * factor / 8)
assert len(text) <= _RATE_COL_WIDTH, text
assert text.endswith(f"{symbol}b"), text
def test_combined_mode_replaces_the_two_columns_with_their_sum():
"""`T` (v4 `network_sum`).
v4 renders a dedicated `bytes_all` field its model computes; v5's schema
has no such field, so the renderer sums the two rates. Same number, and
the sum of two rates over one interval IS the combined rate.
"""
from glances.plugins.network.render_curses_v5 import render
payload = {
"data": [{"interface_name": "eth0", "bytes_recv": 100.0, "bytes_sent": 25.0, "is_up": True}],
"_levels": {},
}
fields = {"bytes_recv": {"short_name": "Rx/s"}, "bytes_sent": {"short_name": "Tx/s"}}
apart = render(payload, fields)
combined = render(payload, fields, view={"network_sum": True})
assert [c.text.strip() for c in apart[0].cells][1:] == ["Rx/s", "Tx/s"]
assert [c.text.strip() for c in combined[0].cells][1:] == ["Rx+Tx/s"]
# 125 B/s x 8 = 1000 bits/s.
assert [c.text.strip() for c in combined[1].cells][1:] == ["1000b"]
def test_combined_mode_drops_the_per_second_suffix_under_byte():
"""v4 labels it `Rx+Tx` under --byte (`network/__init__.py:246-254`)."""
from glances.plugins.network.render_curses_v5 import render
payload = {"data": [], "_levels": {}}
header = render(payload, {}, view={"network_sum": True, "byte": True})[0]
assert header.cells[1].text.strip() == "Rx+Tx"
def test_the_combined_cell_carries_no_threshold_colour():
"""The two fields have their own levels; a sum belongs to neither, and v4
paints its combined cell plain for the same reason."""
from glances.outputs.curses_renderer_v5 import ColorRole
from glances.plugins.network.render_curses_v5 import render
payload = {
"data": [{"interface_name": "eth0", "bytes_recv": 100.0, "bytes_sent": 25.0, "is_up": True}],
"_levels": {"eth0": {"bytes_recv": {"level": "critical", "prominent": True}}},
}
row = render(payload, {}, view={"network_sum": True})[1]
assert row.cells[1].color == ColorRole.DEFAULT
assert row.cells[1].prominent is False
+51 -3
View File
@@ -4108,8 +4108,8 @@ def test_the_help_overlay_renders_every_bound_key():
for key, spec in TuiV5._HOTKEYS.items()
if "hide" in spec or spec.get("group") == "TOGGLE VIEW"
}
# One row per bound key (24 SHOW/HIDE + 7 TOGGLE VIEW), plus `h` itself.
assert len(rendered) == len(bound) + 1 == 32
# One row per bound key (24 SHOW/HIDE + 10 TOGGLE VIEW), plus `h` itself.
assert len(rendered) == len(bound) + 1 == 35
joined = " ".join(rendered)
for key, desc in bound.items():
@@ -4243,7 +4243,17 @@ def test_the_footer_link_toggles_rather_than_only_opening():
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(("key", "flag"), [("b", "byte"), ("6", "meangpu"), ("F", "fs_free_space")])
@pytest.mark.parametrize(
("key", "flag"),
[
("b", "byte"),
("6", "meangpu"),
("F", "fs_free_space"),
("B", "diskio_iops"),
("0", "load_irix"),
("T", "network_sum"),
],
)
def test_a_data_type_key_flips_its_flag(key, flag):
"""2.X-c: `b`, `6` and `F` change HOW a value is shown. They ride the same
override mechanism as the other TOGGLE VIEW keys."""
@@ -4266,3 +4276,41 @@ def test_the_fs_free_space_key_starts_from_the_payload_not_from_serverargs():
# The `default` fixture's fs payload carries no `free_space`, so the
# effective value starts false and one press turns it on.
assert seeded is True
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_B_switches_the_diskio_columns_to_iops():
"""Not a visibility toggle: `B` swaps which pair of fields the two columns
render. The labels come from the schema, so the header follows."""
before = _run_render_probe("diskio")
after = _run_render_probe("diskio", "B")
assert before["pluginColumnHeaders"]["diskio"] == ["DISK I/O", "R/s", "W/s"]
assert after["pluginColumnHeaders"]["diskio"] == ["DISK I/O", "IOR/s", "IOW/s"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_T_combines_the_network_columns():
before = _run_render_probe("network")
after = _run_render_probe("network", "T")
assert before["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx/s", "Tx/s"]
assert after["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx+Tx/s"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_T_drops_the_per_second_suffix_under_byte_mode():
"""v4 labels the combined column `Rx+Tx` under --byte and `Rx+Tx/s`
otherwise (`network/__init__.py:246-254`). The two keys compose."""
assert _run_render_probe("network", "T,b")["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx+Tx"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_0_shows_the_load_averages_as_percentages():
"""Irix mode divides each average by `cpucore`. The fixture's load payload
carries the core count, without which the key correctly does nothing."""
before = _run_render_probe("load")["pluginText"]["load"]
after = _run_render_probe("load", "0")["pluginText"]["load"]
assert "%" not in before, before
assert "%" in after, after