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
+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", {})),