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
+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