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 steward.core.alerts import names_behind
|
||||||
from .routes import name_hottest_sensor
|
from .routes import name_hottest_sensor
|
||||||
names_behind("host_agent", "temp_c_max", 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).
|
# A reboot the agent announced is silence with a reason, for a while (#5756).
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from steward.core.alerts import agents_away
|
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>")
|
@host_agent_bp.get("/vitals/<host_id>")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def host_vitals(host_id: str):
|
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
|
Self-contained fragment, polled by hosts/detail.html. Renders nothing until
|
||||||
the agent reports — the Agent panel below then carries provisioning.
|
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 {}
|
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
|
||||||
norms = await host_svc.host_norms(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
|
# 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.
|
# 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:
|
if reg:
|
||||||
since = datetime.now(timezone.utc) - timedelta(hours=6)
|
since = datetime.now(timezone.utc) - timedelta(hours=6)
|
||||||
for key, resource, metric in (("cpu", host.name, "cpu_pct"),
|
for key, resource, metric in (("cpu", host.name, "cpu_pct"),
|
||||||
("mem", host.name, "mem_used_pct"),
|
("mem", host.name, "mem_used_pct"),
|
||||||
("load", host.name, "load_1m"),
|
("load", host.name, "load_1m"),
|
||||||
|
("temp", host.name, "temp_c_max"),
|
||||||
("disk", host.name + ":/", "disk_used_pct")):
|
("disk", host.name + ":/", "disk_used_pct")):
|
||||||
series_raw[key] = [v for _t, v in await metric_points(
|
series_raw[key] = [v for _t, v in await metric_points(
|
||||||
db, SOURCE_MODULE, resource, metric, since, target=120)]
|
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
|
reporting = bool(reg) and ls is not None
|
||||||
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
||||||
cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct")
|
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", {})
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
|
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))
|
thresholds, host_svc.host_normals(norms, thresholds))
|
||||||
# Each tile's grade; CPU, memory and load within the host's own normal
|
# 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).
|
# 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(
|
return await render_template(
|
||||||
"_host_vitals.html",
|
"_host_vitals.html",
|
||||||
reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core,
|
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,
|
sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst,
|
||||||
grades=grades, usual=usual,
|
grades=grades, usual=usual,
|
||||||
clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})),
|
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
|
{# Host health header — live polled into the host hub: live or stale, the worst
|
||||||
reading against Settings → Thresholds, last seen, then CPU / memory / disk /
|
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
|
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). #}
|
reports (the Agent panel below then shows provisioning). #}
|
||||||
{% from "_macros.html" import status_badge, stat_tile %}
|
{% from "_macros.html" import status_badge, stat_tile %}
|
||||||
{% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %}
|
{% 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 {} %}
|
{% set grades = grades or {} %}
|
||||||
{% if reporting %}
|
{% if reporting %}
|
||||||
<section class="card host-health" aria-label="Host health">
|
<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') }}
|
{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
|
||||||
<span class="host-health-summary">
|
<span class="host-health-summary">
|
||||||
{%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
|
{%- 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.
|
{%- elif usual %}Every reading is within its thresholds or its usual level for this hour.
|
||||||
{%- else %}Every reading is within its thresholds.{% endif -%}
|
{%- else %}Every reading is within its thresholds.{% endif -%}
|
||||||
</span>
|
</span>
|
||||||
@@ -24,13 +25,17 @@
|
|||||||
{# Charts, every mount, interface, disk and temperature sensor. #}
|
{# 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>
|
<a href="/plugins/host_agent/{{ reg.host_id }}/" class="btn btn-sm btn-ghost" style="margin-left:auto;">Full metrics →</a>
|
||||||
</div>
|
</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("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) }}
|
{{ 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) %}
|
{%- 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 ""),
|
{{ 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) }}
|
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) }}
|
{{ 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>
|
</div>
|
||||||
{% if usual and not stale %}
|
{% if usual and not stale %}
|
||||||
<p class="field-hint" style="margin-top:0.6rem;">{{ usual | join("; ") | capitalize }}.</p>
|
<p class="field-hint" style="margin-top:0.6rem;">{{ usual | join("; ") | capitalize }}.</p>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Host tools: the fleet, one host in depth, and a host's agent metrics."""
|
"""Host tools: the fleet, one host in depth, and a host's agent metrics."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -19,6 +21,18 @@ from .tickets import open_tickets_for
|
|||||||
|
|
||||||
AGENT_SOURCE = "host_agent"
|
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]:
|
async def _all_hosts(db) -> list[Host]:
|
||||||
return list((await db.execute(select(Host).order_by(Host.name))).scalars())
|
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)),
|
"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)),
|
"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)),
|
"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"],
|
"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})
|
groups["mounts"].append({"mount": name, "part": name, **metrics})
|
||||||
else:
|
else:
|
||||||
groups["other"].append({"part": name, **metrics})
|
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("
|
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>").')
|
f'source_module="host_agent", resource="{host_name}:<part>").')
|
||||||
out: dict = {}
|
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
|
The agent's values are `agent.metrics` (host-level) and its sub-resources
|
||||||
grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io`
|
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,
|
`total` and `truncated`; `cpu_cores` summarises per-core CPU (count,
|
||||||
min/avg/max, the three busiest). One part's history is query_metrics with
|
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
|
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; }
|
.host-health-summary { font-size: 0.92rem; }
|
||||||
/* Its four tiles share the card's width (two a row on a phone); each sparkline
|
/* Its four tiles share the card's width (two a row on a phone); each sparkline
|
||||||
stretches across its tile. */
|
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 { min-width: 0; }
|
||||||
.host-health-tiles .stat-tile-hint { margin-top: 0.25rem; }
|
.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)); } }
|
@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;">
|
<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>
|
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
|
||||||
{% if session.user_role in ['operator', 'admin'] %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if uptime %}
|
{% if uptime %}
|
||||||
<span style="font-size:0.8rem;color:var(--text-muted);">
|
<span style="font-size:0.8rem;color:var(--text-muted);">
|
||||||
|
|||||||
@@ -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);
|
# Monitors are added on their own form, filed under this host (#5277);
|
||||||
# each row's own page has the labelled Edit/Pause/Delete.
|
# each row's own page has the labelled Edit/Pause/Delete.
|
||||||
assert 'href="/monitors/new?host_id=h1"' in page
|
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
|
assert "row-actions" not in page and 'action="/monitors/add"' not in page
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -312,10 +312,25 @@ def test_get_host_groups_parts_by_kind_and_summarises_cores():
|
|||||||
assert got["disk_io"]["items"][0]["device"] == "sda"
|
assert got["disk_io"]["items"][0]["device"] == "sda"
|
||||||
assert [d["device"] for d in got["drive_health"]["items"]] == ["nvme0", "sda"] # failing first
|
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",
|
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
|
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():
|
def test_one_silent_agent_is_one_finding():
|
||||||
"""#5721: VDNT-Docker03's silent agent read as three findings. Its agent-
|
"""#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
|
offline alert speaks for it, the stale-agent finding goes, and its rules
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ def test_worst_reading(readings, worst):
|
|||||||
assert worst_reading(readings, TH) == 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"),
|
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"):
|
async with app.test_request_context("/plugins/host_agent/vitals/h1"):
|
||||||
page = await render_plugin_template(
|
page = await render_plugin_template(
|
||||||
app, "host_agent", "_host_vitals.html", reporting=True, stale=stale,
|
app, "host_agent", "_host_vitals.html", reporting=True, stale=stale,
|
||||||
reg=SimpleNamespace(host_id="h1", agent_version="1.9.0",
|
reg=SimpleNamespace(host_id="h1", agent_version="1.9.0",
|
||||||
last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min)),
|
last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min)),
|
||||||
cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20,
|
cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20, temp=temp, hottest=hottest,
|
||||||
sparks={"cpu": "", "mem": "", "disk": "", "load": ""},
|
sparks={"cpu": "", "mem": "", "disk": "", "load": "", "temp": "<svg></svg>"},
|
||||||
psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH))
|
psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH))
|
||||||
return re.sub(r">\s+<", "><", " ".join(page.split()))
|
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)
|
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):
|
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)
|
page = await _vitals(app, mem=93.0, stale=True, seen_min=20)
|
||||||
assert '<span class="badge badge-warn">Stale</span>' in page
|
assert '<span class="badge badge-warn">Stale</span>' in page
|
||||||
|
|||||||
Reference in New Issue
Block a user