mirror of
https://github.com/nicolargo/glances.git
synced 2026-09-23 18:45:44 -04:00
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:
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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. */
|
||||
|
||||
BIN
Binary file not shown.
@@ -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).",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 } } },
|
||||
};
|
||||
|
||||
@@ -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), "-");
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user