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)
- 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:
@@ -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
|
||||
|
||||
@@ -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)); } }
|
||||
|
||||
@@ -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);">
|
||||
|
||||
Reference in New Issue
Block a user