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