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)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m58s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m7s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 16:00:49 -05:00
co-authored by Claude Opus 5.5
parent c38fd68e48
commit 6eeb4039d7
9 changed files with 91 additions and 17 deletions
+4
View File
@@ -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
+13 -5
View File
@@ -1003,7 +1003,7 @@ def _new_token_pair() -> tuple[str, str]:
@host_agent_bp.get("/vitals/<host_id>")
@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", {})),
@@ -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 %}
<section class="card host-health" aria-label="Host health">
@@ -12,7 +13,7 @@
{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
<span class="host-health-summary">
{%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ deg(worst[1]) if worst[0] == "Temperature" else pct(worst[1]) }}</span> 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 -%}
</span>
@@ -24,13 +25,17 @@
{# Charts, every mount, interface, disk and temperature sensor. #}
<a href="/plugins/host_agent/{{ reg.host_id }}/" class="btn btn-sm btn-ghost" style="margin-left:auto;">Full metrics →</a>
</div>
<div class="stat-strip host-health-tiles" style="margin-bottom:0;">
<div class="stat-strip host-health-tiles" style="margin-bottom:0;--tiles:{{ 5 if temp is not none else 4 }};">
{{ 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 %}<span class="stat-tile-sensor" title="{{ hottest }}">{{ hottest }}</span>{% endif %}{% endset %}
{{ stat_tile("Temperature", deg(temp), state=status_class(temp, 'temp'), hint=temp_hint) }}
{% endif %}
</div>
{% if usual and not stale %}
<p class="field-hint" style="margin-top:0.6rem;">{{ usual | join("; ") | capitalize }}.</p>
+24 -2
View File
@@ -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}:<part>").')
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="<host>:<part>", where `part` is
+3 -1
View File
@@ -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)); } }
+1 -1
View File
@@ -61,7 +61,7 @@
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
{% if session.user_role in ['operator', 'admin'] %}
<a href="/monitors/new?host_id={{ host.id }}" class="btn btn-ghost btn-sm" style="margin-left:auto;margin-right:0.75rem;">Add monitor</a>
<a href="/monitors/new?host_id={{ host.id }}" class="btn btn-ghost btn-sm" style="margin-left:auto;margin-right:0.75rem;flex-shrink:0;" title="Add a monitor for this host">Add</a>
{% endif %}
{% if uptime %}
<span style="font-size:0.8rem;color:var(--text-muted);">
+2
View File
@@ -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</a>' in page
assert "row-actions" not in page and 'action="/monitors/add"' not in page
+16 -1
View File
@@ -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
+20 -4
View File
@@ -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": "<svg></svg>"},
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"><span class="stat-tile-label">Temperature</span>'
'<span class="stat-tile-value">62°C</span>') in page
assert '<svg></svg><span class="stat-tile-sensor" title="Package id 0">Package id 0</span>' 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 '<span class="tone-crit">Temperature 88°C</span> is past its critical threshold.' in page
assert 'class="stat-tile stat-crit"><span class="stat-tile-label">Temperature</span>' 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 '<span class="badge badge-warn">Stale</span>' in page