From 6eeb4039d7a60de9f2eb571b31d18da8629b0c2f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 3 Nov 2026 16:00:49 -0500 Subject: [PATCH] feat(hosts): the live strip shows the hottest sensor's temperature, get_host marks sensors that don't count, and the Monitors card's button reads "Add" (#6803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The host page's live strip gains a Temperature tile: temp_c_max (real thermometers only), its last 6 h and the sensor setting it, graded by Settings → Thresholds and counted in the header's worst reading, in °C. - MCP get_host: each temperature carries `counted`; an unwired input is `counted: false` with `not_counted_because`, listed after the real ones. host_agent registers the judge (judge_sensors), so core imports no plugin. - The Monitors card's "Add monitor" wrapped into two lines beside the uptime; it reads "Add", titled "Add a monitor for this host". Co-Authored-By: Claude Opus 5.5 --- plugins/host_agent/__init__.py | 4 +++ plugins/host_agent/routes.py | 18 +++++++++---- .../host_agent/templates/_host_vitals.html | 11 +++++--- steward/mcp/tools/hosts.py | 26 +++++++++++++++++-- steward/static/steward.css | 4 ++- steward/templates/hosts/detail.html | 2 +- tests/core/test_host_page.py | 2 ++ tests/mcp/test_tools.py | 17 +++++++++++- tests/plugins/host_agent/test_host_health.py | 24 ++++++++++++++--- 9 files changed, 91 insertions(+), 17 deletions(-) diff --git a/plugins/host_agent/__init__.py b/plugins/host_agent/__init__.py index b497d70..967e208 100644 --- a/plugins/host_agent/__init__.py +++ b/plugins/host_agent/__init__.py @@ -29,6 +29,10 @@ def setup(app: "Quart") -> None: from steward.core.alerts import names_behind from .routes import name_hottest_sensor names_behind("host_agent", "temp_c_max", name_hottest_sensor) + # get_host over MCP marks the sensors the host's temperature leaves out. + from steward.mcp.tools.hosts import judge_sensors + from .routes import split_temps + judge_sensors(lambda host, temps: {k: why for k, (_c, why) in split_temps(host, temps)[1].items()}) # A reboot the agent announced is silence with a reason, for a while (#5756). from datetime import datetime, timezone from steward.core.alerts import agents_away diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index bf32cc6..04b7469 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -1003,7 +1003,7 @@ def _new_token_pair() -> tuple[str, str]: @host_agent_bp.get("/vitals/") @require_role(UserRole.viewer) async def host_vitals(host_id: str): - """Compact live vitals strip (CPU/MEM/DISK/LOAD + pressure) for the Hosts hub. + """Compact live vitals strip (CPU/MEM/DISK/LOAD/temperature + pressure) for the Hosts hub. Self-contained fragment, polled by hosts/detail.html. Renders nothing until the agent reports — the Agent panel below then carries provisioning. @@ -1018,15 +1018,16 @@ async def host_vitals(host_id: str): latest = await _latest_metrics_for_host(db, host.name) if reg else {} norms = await host_svc.host_norms(db, host.name) if reg else {} - # The last 6 h for the at-a-glance sparklines (cpu/mem/load host-level, + # The last 6 h for the at-a-glance sparklines (cpu/mem/load/temp host-level, # disk from the root mount), averaged into ~120 points in SQL, so the # line spans the whole window and only what is drawn is fetched. - series_raw: dict[str, list[float]] = {"cpu": [], "mem": [], "disk": [], "load": []} + series_raw: dict[str, list[float]] = {"cpu": [], "mem": [], "disk": [], "load": [], "temp": []} if reg: since = datetime.now(timezone.utc) - timedelta(hours=6) for key, resource, metric in (("cpu", host.name, "cpu_pct"), ("mem", host.name, "mem_used_pct"), ("load", host.name, "load_1m"), + ("temp", host.name, "temp_c_max"), ("disk", host.name + ":/", "disk_used_pct")): series_raw[key] = [v for _t, v in await metric_points( db, SOURCE_MODULE, resource, metric, since, target=120)] @@ -1053,9 +1054,16 @@ async def host_vitals(host_id: str): reporting = bool(reg) and ls is not None stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds() cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct") + # The hottest sensor that counts (temp_c_max leaves out unwired inputs), + # named so the tile says what it is reading. + temp = hostlvl.get("temp_c_max") + prefix = host.name + ":temp:" + hottest = hottest_sensor(host.name, {r[len(prefix):]: m["temp_c"] for r, m in latest.items() + if r.startswith(prefix) and m.get("temp_c") is not None}) thresholds = current_app.config.get("THRESHOLDS", {}) worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"), - ("Inodes on /", inodes_root, "disk"), ("Load per core", load_per_core, "load")), + ("Inodes on /", inodes_root, "disk"), ("Load per core", load_per_core, "load"), + ("Temperature", temp, "temp")), thresholds, host_svc.host_normals(norms, thresholds)) # Each tile's grade; CPU, memory and load within the host's own normal # for the hour read healthy, and say what is usual (#5742). @@ -1068,7 +1076,7 @@ async def host_vitals(host_id: str): return await render_template( "_host_vitals.html", reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core, - inodes_root=inodes_root, + inodes_root=inodes_root, temp=temp, hottest=hottest[0].rsplit(" at ", 1)[0] if hottest else None, sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst, grades=grades, usual=usual, clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})), diff --git a/plugins/host_agent/templates/_host_vitals.html b/plugins/host_agent/templates/_host_vitals.html index 8b4d1ea..b5249d4 100644 --- a/plugins/host_agent/templates/_host_vitals.html +++ b/plugins/host_agent/templates/_host_vitals.html @@ -1,10 +1,11 @@ {# Host health header — live polled into the host hub: live or stale, the worst reading against Settings → Thresholds, last seen, then CPU / memory / disk / - load tiles graded by the same cutoffs (`grades`; CPU, memory and load within + load tiles, and the hottest sensor that counts with its name, graded by the same cutoffs (`grades`; CPU, memory and load within the host's own normal for the hour read healthy, and `usual` says so). Renders nothing until the agent reports (the Agent panel below then shows provisioning). #} {% from "_macros.html" import status_badge, stat_tile %} {% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %} +{% macro deg(v) %}{{ '%.0f°C'|format(v) if v is not none else '—' }}{% endmacro %} {% set grades = grades or {} %} {% if reporting %}
@@ -12,7 +13,7 @@ {{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }} {%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then. - {%- elif worst %}{{ worst[0] }} {{ pct(worst[1]) }} is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold. + {%- elif worst %}{{ worst[0] }} {{ deg(worst[1]) if worst[0] == "Temperature" else pct(worst[1]) }} is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold. {%- elif usual %}Every reading is within its thresholds or its usual level for this hour. {%- else %}Every reading is within its thresholds.{% endif -%} @@ -24,13 +25,17 @@ {# Charts, every mount, interface, disk and temperature sensor. #} Full metrics → -
+
{{ stat_tile("CPU", pct(cpu), state=(grades.cpu or [status_class(cpu, 'cpu')])[0], hint=sparks.cpu|safe) }} {{ stat_tile("Memory", pct(mem), state=(grades.mem or [status_class(mem, 'mem')])[0], hint=sparks.mem|safe) }} {%- set inodes_worse = inodes_root is defined and inodes_root is not none and (disk_root is none or inodes_root > disk_root) %} {{ stat_tile("Disk /", pct(disk_root) ~ (" · inodes " ~ pct(inodes_root) if inodes_worse else ""), state=(grades.disk or [status_class(disk_root, 'disk')])[0], hint=sparks.disk|safe) }} {{ stat_tile("Load per core", pct(load_per_core), state=(grades.load or [status_class(load_per_core, 'load')])[0], hint=sparks.load|safe) }} + {% if temp is not none %} + {%- set temp_hint %}{{ sparks.temp|safe }}{% if hottest %}{{ hottest }}{% endif %}{% endset %} + {{ stat_tile("Temperature", deg(temp), state=status_class(temp, 'temp'), hint=temp_hint) }} + {% endif %}
{% if usual and not stale %}

{{ usual | join("; ") | capitalize }}.

diff --git a/steward/mcp/tools/hosts.py b/steward/mcp/tools/hosts.py index 1098219..c99388e 100644 --- a/steward/mcp/tools/hosts.py +++ b/steward/mcp/tools/hosts.py @@ -1,6 +1,8 @@ """Host tools: the fleet, one host in depth, and a host's agent metrics.""" from __future__ import annotations +from typing import Callable + from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -19,6 +21,18 @@ from .tickets import open_tickets_for AGENT_SOURCE = "host_agent" +# Which of a host's sensors don't count as its temperature, and why: unwired +# inputs read a constant or move against the CPU. The plugin that knows its +# sensors registers the judge; with none, every sensor counts. +_UNCOUNTED_SENSORS: Callable[[str, dict[str, float]], dict[str, str]] | None = None + + +def judge_sensors(judge: Callable[[str, dict[str, float]], dict[str, str]]) -> None: + """`judge(host_name, {sensor: celsius})` → {sensor: why} for each sensor + that is not a thermometer; get_host marks those `counted: false`.""" + global _UNCOUNTED_SENSORS + _UNCOUNTED_SENSORS = judge + async def _all_hosts(db) -> list[Host]: return list((await db.execute(select(Host).order_by(Host.name))).scalars()) @@ -50,7 +64,7 @@ _PART_ORDER = { "interfaces": lambda r: -((r.get("net_rx_bps") or 0) + (r.get("net_tx_bps") or 0)), "disk_io": lambda r: -((r.get("disk_read_bps") or 0) + (r.get("disk_write_bps") or 0)), "drive_health": lambda r: (-(r.get("drive_failing") or 0), -(r.get("drive_bad_sectors") or 0)), - "temperatures": lambda r: -(r.get("temp_c") or 0), + "temperatures": lambda r: (not r.get("counted", True), -(r.get("temp_c") or 0)), "other": lambda r: r["part"], } @@ -79,6 +93,12 @@ def agent_parts(parts: dict[str, dict], host_name: str, limit: int) -> dict: groups["mounts"].append({"mount": name, "part": name, **metrics}) else: groups["other"].append({"part": name, **metrics}) + temps = {r["sensor"]: r["temp_c"] for r in groups["temperatures"] if r.get("temp_c") is not None} + uncounted = _UNCOUNTED_SENSORS(host_name, temps) if _UNCOUNTED_SENSORS and temps else {} + for r in groups["temperatures"]: + r["counted"] = r["sensor"] not in uncounted + if not r["counted"]: + r["not_counted_because"] = uncounted[r["sensor"]] more = (f"Pass a larger `parts_limit` (max 200), or read one part with query_metrics(" f'source_module="host_agent", resource="{host_name}:").') out: dict = {} @@ -148,7 +168,9 @@ async def get_host(host: str, parts_limit: int = 20) -> dict: The agent's values are `agent.metrics` (host-level) and its sub-resources grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io` - (busiest first), `temperatures` (hottest first), each with `items`, + (busiest first), `temperatures` (the ones that count as the host's + temperature hottest first, then those left out of it: `counted: false` + with `not_counted_because`, likely nothing wired to them), each with `items`, `total` and `truncated`; `cpu_cores` summarises per-core CPU (count, min/avg/max, the three busiest). One part's history is query_metrics with source_module="host_agent" and resource=":", where `part` is diff --git a/steward/static/steward.css b/steward/static/steward.css index 023fe2d..782c386 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -446,7 +446,9 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .host-health-summary { font-size: 0.92rem; } /* Its four tiles share the card's width (two a row on a phone); each sparkline stretches across its tile. */ -.host-health-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.host-health-tiles { grid-template-columns: repeat(var(--tiles, 4), minmax(0, 1fr)); } +/* Which sensor the temperature tile reads, under its line; cut with … when long. */ +.stat-tile-sensor { display: block; font-size: 0.72rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .host-health-tiles .stat-tile { min-width: 0; } .host-health-tiles .stat-tile-hint { margin-top: 0.25rem; } @media (max-width: 640px) { .host-health-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index dbb98ef..e49683b 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -61,7 +61,7 @@

Monitors

{% if session.user_role in ['operator', 'admin'] %} - Add monitor + Add {% endif %} {% if uptime %} diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index 972903a..203d24e 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -42,6 +42,8 @@ async def test_ansible_comes_before_the_docker_list(app, monkeypatch): # Monitors are added on their own form, filed under this host (#5277); # each row's own page has the labelled Edit/Pause/Delete. assert 'href="/monitors/new?host_id=h1"' in page + # The card's header shares one line with the uptime: a one-word button keeps it whole. + assert 'title="Add a monitor for this host">Add' in page assert "row-actions" not in page and 'action="/monitors/add"' not in page diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py index 92f89df..772cc42 100644 --- a/tests/mcp/test_tools.py +++ b/tests/mcp/test_tools.py @@ -312,10 +312,25 @@ def test_get_host_groups_parts_by_kind_and_summarises_cores(): assert got["disk_io"]["items"][0]["device"] == "sda" assert [d["device"] for d in got["drive_health"]["items"]] == ["nvme0", "sda"] # failing first assert got["temperatures"]["items"][0] == {"sensor": "Package id 0", "part": "temp:Package id 0", - "temp_c": 61.0} + "temp_c": 61.0, "counted": True} assert "other" not in got +def test_get_host_marks_the_sensors_the_hosts_temperature_leaves_out(monkeypatch): + """An unwired input reading 115 °C is listed after the real sensors, with + why it doesn't count, so a reader doesn't take it for heat.""" + from steward.mcp.tools import hosts + monkeypatch.setattr(hosts, "_UNCOUNTED_SENSORS", + lambda host, temps: {k: "reads a value no wired sensor gives" + for k, c in temps.items() if c >= 100}) + got = hosts.agent_parts({"temp:SYSTIN": {"temp_c": 115.0}, "temp:Package id 0": {"temp_c": 61.0}, + "temp:nvme": {"temp_c": 40.0}}, "box", limit=10)["temperatures"]["items"] + assert [t["sensor"] for t in got] == ["Package id 0", "nvme", "SYSTIN"] + assert got[0]["counted"] is True and "not_counted_because" not in got[0] + assert got[2]["counted"] is False + assert got[2]["not_counted_because"] == "reads a value no wired sensor gives" + + def test_one_silent_agent_is_one_finding(): """#5721: VDNT-Docker03's silent agent read as three findings. Its agent- offline alert speaks for it, the stale-agent finding goes, and its rules diff --git a/tests/plugins/host_agent/test_host_health.py b/tests/plugins/host_agent/test_host_health.py index 85fc16c..dfeb74a 100644 --- a/tests/plugins/host_agent/test_host_health.py +++ b/tests/plugins/host_agent/test_host_health.py @@ -25,16 +25,16 @@ def test_worst_reading(readings, worst): assert worst_reading(readings, TH) == worst -async def _vitals(app, *, cpu=40.0, mem=50.0, stale=False, seen_min=1): +async def _vitals(app, *, cpu=40.0, mem=50.0, stale=False, seen_min=1, temp=None, hottest=None): readings = (("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", 30.0, "disk"), - ("Load per core", 20, "load")) + ("Load per core", 20, "load"), ("Temperature", temp, "temp")) async with app.test_request_context("/plugins/host_agent/vitals/h1"): page = await render_plugin_template( app, "host_agent", "_host_vitals.html", reporting=True, stale=stale, reg=SimpleNamespace(host_id="h1", agent_version="1.9.0", last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min)), - cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20, - sparks={"cpu": "", "mem": "", "disk": "", "load": ""}, + cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20, temp=temp, hottest=hottest, + sparks={"cpu": "", "mem": "", "disk": "", "load": "", "temp": ""}, psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH)) return re.sub(r">\s+<", "><", " ".join(page.split())) @@ -59,6 +59,22 @@ async def test_the_strip_at_the_top_of_the_page_leads_to_the_full_metrics(app): assert 'href="/plugins/host_agent/h1/"' in await _vitals(app) +async def test_the_strip_shows_the_hottest_sensor_with_its_name_and_line(app): + page = await _vitals(app, temp=62.0, hottest="Package id 0") + assert "--tiles:5;" in page + assert ('class="stat-tile stat-ok">Temperature' + '62°C') in page + assert 'Package id 0' in page + without = await _vitals(app) + assert "--tiles:4;" in without and ">Temperature<" not in without + + +async def test_a_host_past_its_heat_threshold_says_so_in_degrees(app): + page = await _vitals(app, temp=88.0, hottest="Package id 0") # crit at 85 + assert 'Temperature 88°C is past its critical threshold.' in page + assert 'class="stat-tile stat-crit">Temperature' in page + + async def test_a_stale_host_says_since_when_before_any_reading(app): page = await _vitals(app, mem=93.0, stale=True, seen_min=20) assert 'Stale' in page