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:
@@ -13,10 +13,10 @@ each argument's empty value means, and what comes back.
|
||||
from __future__ import annotations
|
||||
|
||||
from . import (alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session,
|
||||
tickets, timeline)
|
||||
thresholds, tickets, timeline)
|
||||
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, tickets,
|
||||
automation, backups)
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, thresholds, metrics, audit, timeline,
|
||||
tickets, automation, backups)
|
||||
|
||||
|
||||
def register_all(register_tool) -> None:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""The cutoffs a host's readings are graded by, as Settings → Thresholds sets
|
||||
them: amber past `warn`, red past `crit`."""
|
||||
from __future__ import annotations
|
||||
|
||||
from steward.core.settings import DEFAULTS, get_all_settings, set_setting, to_thresholds_cfg
|
||||
from steward.mcp._context import mcp_app
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._shape import db_session
|
||||
|
||||
# Each graded reading, its unit, and what it measures.
|
||||
READINGS = {
|
||||
"cpu": ("%", "CPU use"),
|
||||
"mem": ("%", "memory used"),
|
||||
"disk": ("%", "fullest mount's space used"),
|
||||
"load": ("%", "1-minute load per core"),
|
||||
"temp": ("°C", "hottest real thermometer"),
|
||||
}
|
||||
|
||||
|
||||
def _view(settings: dict) -> dict:
|
||||
cfg = to_thresholds_cfg(settings)
|
||||
return {kind: {"warn": cfg[kind]["warn"], "crit": cfg[kind]["crit"], "unit": unit, "measures": what,
|
||||
"default": {"warn": DEFAULTS[f"thresholds.{kind}_warn"],
|
||||
"crit": DEFAULTS[f"thresholds.{kind}_crit"]}}
|
||||
for kind, (unit, what) in READINGS.items()}
|
||||
|
||||
|
||||
async def get_thresholds() -> dict:
|
||||
"""The cutoffs host readings are graded by, as Settings → Thresholds
|
||||
sets them: per reading (cpu, mem, disk, load, temp), `warn` (amber past
|
||||
it) and `crit` (red past it) in `unit`, with Steward's default for each.
|
||||
The host pages, the Overview and get_overview grade by these. Alert
|
||||
rules have their own cutoffs (list_alert_rules); these don't fire
|
||||
anything. set_thresholds (admin) changes them.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
return _view(await get_all_settings(db))
|
||||
|
||||
|
||||
async def set_thresholds(cutoffs: dict[str, dict[str, float]]) -> dict:
|
||||
"""Change the cutoffs host readings are graded by, as Settings →
|
||||
Thresholds does. Takes effect on the next page load; audited.
|
||||
|
||||
cutoffs: {reading: {"warn": n, "crit": n}} for any of get_thresholds'
|
||||
readings, e.g. {"temp": {"crit": 90}}. Either of warn and crit may be
|
||||
left out and keeps its value; warn must stay below crit. A reading not
|
||||
named is left as it is. Returns the cutoffs as they now stand.
|
||||
"""
|
||||
unknown = sorted(set(cutoffs or {}) - set(READINGS))
|
||||
if unknown:
|
||||
raise ValueError(f"No reading named {', '.join(unknown)}; they are {', '.join(READINGS)}.")
|
||||
if not cutoffs:
|
||||
raise ValueError("Nothing to change: pass cutoffs, e.g. {\"temp\": {\"crit\": 90}}.")
|
||||
async with db_session() as db:
|
||||
current = _view(await get_all_settings(db))
|
||||
changes: dict = {}
|
||||
for kind, given in cutoffs.items():
|
||||
extra = sorted(set(given or {}) - {"warn", "crit"})
|
||||
if extra or not given:
|
||||
raise ValueError(f"{kind} takes warn and/or crit, not {', '.join(extra) or 'nothing'}.")
|
||||
new = {}
|
||||
for edge in ("warn", "crit"):
|
||||
if edge not in given:
|
||||
new[edge] = current[kind][edge]
|
||||
continue
|
||||
try:
|
||||
value = float(given[edge])
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{kind} {edge} must be a number.") from None
|
||||
if not value > 0:
|
||||
raise ValueError(f"{kind} {edge} must be more than 0.")
|
||||
new[edge] = int(value) if value.is_integer() else value
|
||||
changes[f"thresholds.{kind}_{edge}"] = new[edge]
|
||||
if new["warn"] >= new["crit"]:
|
||||
raise ValueError(f"{kind} warn ({new['warn']}) must be below its crit ({new['crit']}).")
|
||||
app = mcp_app()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
before = await get_all_settings(db)
|
||||
for key, value in changes.items():
|
||||
await set_setting(db, key, value)
|
||||
fresh = await get_all_settings(db)
|
||||
app.config["THRESHOLDS"] = to_thresholds_cfg(fresh)
|
||||
await audit_mcp("settings.saved", "settings", "thresholds",
|
||||
{"section": "thresholds",
|
||||
"changes": {k: {"from": before.get(k), "to": v} for k, v in changes.items()}})
|
||||
return _view(fresh)
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(get_thresholds, role=UserRole.viewer, read_only=True)
|
||||
register_tool(set_thresholds, role=UserRole.admin, read_only=False)
|
||||
Reference in New Issue
Block a user