feat(hosts): the hottest sensor is named with its chip, and the host-reading cutoffs are read and set over MCP (#6806)
Agent 1.27.0 sends each temperature sensor's hwmon chip. Steward keeps the chip per host and label beside the reading, so a sensor's metric key stays its label, and the strip and the heat alert name it as "NVMe · Sensor 2" rather than a bare "Sensor 2". get_thresholds (viewer) and set_thresholds (admin, audited) read and change the warn/crit cutoffs CPU, memory, disk, load and temperature are graded by, as Settings → Thresholds does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
|
||||
from datetime import datetime, timezone
|
||||
|
||||
AGENT_VERSION = "1.26.1"
|
||||
AGENT_VERSION = "1.27.0"
|
||||
|
||||
# Default path to the local Docker Engine socket. Overridable via the
|
||||
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||
@@ -480,7 +480,9 @@ def nfs_delays(cur: dict, prev: dict, dt: float) -> list:
|
||||
|
||||
|
||||
def collect_temps() -> list:
|
||||
"""Return [{label, celsius}] from /sys/class/hwmon (best-effort, may be empty)."""
|
||||
"""Return [{label, celsius, chip}] from /sys/class/hwmon (best-effort, may
|
||||
be empty). `chip` is the hwmon driver's name (coretemp, nvme, nct6798…),
|
||||
which says what a bare label like "Sensor 2" is measuring."""
|
||||
out: list = []
|
||||
try:
|
||||
chips = os.listdir(HWMON_DIR)
|
||||
@@ -511,7 +513,7 @@ def collect_temps() -> list:
|
||||
except OSError:
|
||||
pass
|
||||
name = label or (f"{chip}_{idx}" if chip else idx)
|
||||
out.append({"label": name, "celsius": round(milli / 1000.0, 1)})
|
||||
out.append({"label": name, "celsius": round(milli / 1000.0, 1), "chip": chip})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -124,13 +124,15 @@ def is_thermometer(host_name: str, label: str, celsius: float) -> bool:
|
||||
|
||||
def hottest_sensor(host_name: str, temps: dict[str, float]) -> list[str]:
|
||||
"""Which sensor sets a host's temperature, as an alert on it says:
|
||||
["Package id 0 at 86°C"], or [] with no thermometer among `temps`."""
|
||||
["CPU · Package id 0 at 86°C"], or [] with no thermometer among `temps`.
|
||||
The chip leads the label when the host's agent has said which it is."""
|
||||
from steward.core.formatting import metric_value
|
||||
real = {label: c for label, c in temps.items() if is_thermometer(host_name, label, c)}
|
||||
if not real:
|
||||
return []
|
||||
from .sensors import sensor_name
|
||||
label = max(real, key=real.get)
|
||||
return [f"{label} at {metric_value('temp_c', real[label])}"]
|
||||
return [f"{sensor_name(host_name, label)} at {metric_value('temp_c', real[label])}"]
|
||||
|
||||
|
||||
async def name_hottest_sensor(session, resource: str) -> list[str]:
|
||||
@@ -374,6 +376,8 @@ def _expand_sample_to_metrics(
|
||||
# counts only readings that can be real (#5759), from sensors that behave
|
||||
# like thermometers (#5783).
|
||||
max_temp: float | None = None
|
||||
from .sensors import note_chips
|
||||
note_chips(host_name, sample.get("temps"))
|
||||
for t in sample.get("temps") or []:
|
||||
c = t.get("celsius")
|
||||
if c is None:
|
||||
|
||||
@@ -53,6 +53,51 @@ def unwired(host_name: str) -> dict[str, str]:
|
||||
return _unwired.get(host_name, {})
|
||||
|
||||
|
||||
# {host name: {sensor label: hwmon chip}}, as the host's latest sample said.
|
||||
# A sensor's metric is keyed by its label alone; the chip only names it.
|
||||
_chips: dict[str, dict[str, str]] = {}
|
||||
|
||||
# What each hwmon driver measures, in the words a page uses. A driver not
|
||||
# listed is shown by its own name.
|
||||
_CHIP_WORDS = (
|
||||
(re.compile(r"^(coretemp|k10temp|zenpower|cpu_thermal)$"), "CPU"),
|
||||
(re.compile(r"^nvme$"), "NVMe"),
|
||||
(re.compile(r"^drivetemp$"), "Drive"),
|
||||
(re.compile(r"^(nct\d+|it\d+|w83\w+|f71\w+)$"), "Board"),
|
||||
(re.compile(r"^pch_\w+$"), "Chipset"),
|
||||
(re.compile(r"^(amdgpu|radeon|nouveau|i915|xe)$"), "GPU"),
|
||||
(re.compile(r"^(spd5118|jc42)$"), "Memory"),
|
||||
(re.compile(r"^iwlwifi"), "Wi-Fi"),
|
||||
(re.compile(r"^acpitz$"), "ACPI"),
|
||||
)
|
||||
|
||||
|
||||
def note_chips(host_name: str, temps) -> None:
|
||||
"""Remember which chip each of a host's sensors is on, from a sample's
|
||||
temps. An agent that doesn't say leaves what is known as it is."""
|
||||
seen = {str(t["label"]): str(t["chip"]) for t in temps or [] if t.get("chip")}
|
||||
if seen:
|
||||
_chips[host_name] = seen
|
||||
|
||||
|
||||
def chip_word(chip: str) -> str:
|
||||
"""What a hwmon chip measures ("nvme" → "NVMe"), or its own name."""
|
||||
for pattern, word in _CHIP_WORDS:
|
||||
if pattern.match(chip):
|
||||
return word
|
||||
return chip
|
||||
|
||||
|
||||
def sensor_name(host_name: str, label: str) -> str:
|
||||
"""A sensor as a page names it: its chip, then its label, as in
|
||||
"NVMe · Sensor 2". The label alone when the chip isn't known or the
|
||||
label already carries it ("nvme_temp1")."""
|
||||
chip = _chips.get(host_name, {}).get(label)
|
||||
if not chip or label.lower().startswith(chip.lower()):
|
||||
return label
|
||||
return f"{chip_word(chip)} · {label}"
|
||||
|
||||
|
||||
def reference_label(labels) -> str | None:
|
||||
"""The sensor that reads the host's CPU, or None when it has none."""
|
||||
labels = sorted(labels)
|
||||
|
||||
Reference in New Issue
Block a user