feat(dashboard): Hosts widget worst first, load against cores, one home per fact (#5251)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Failing after 1m31s
CI / publish (push) Skipped

The Hosts — Overview widget opens with a verdict ("VDNT-Util01: CPU high."
or "All 3 hosts healthy."), then one row per host under one header: CPU,
memory, disk / and load. Rows sort by host_glance: a down monitor, a
reading past critical, a quiet agent, a warning, then the healthy. Load
reads "16.8 / 8 cores", graded per core as Settings → Thresholds intends
(cores counted from the per-core CPU sub-resources, as on the host page).
A quiet agent's last figures are shown uncoloured, with when it last reported.

Ping and 24h uptime are gone from this widget: they belong to Monitors. So
are the unlabelled CPU sparkline and the 30-day uptime query it no longer
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:15:01 -04:00
co-authored by Claude Opus 5.5
parent b5fcccba5f
commit 17abe450cc
5 changed files with 170 additions and 92 deletions
+5 -11
View File
@@ -27,21 +27,15 @@ def _ansible_source_names() -> list[str]:
@hosts_bp.get("/overview/widget")
@require_role(UserRole.viewer)
async def overview_widget():
"""Dashboard widget: unified per-host monitor + agent glance, linking to the hub."""
"""Dashboard widget: each host's resources, worst first (#5251). Uptime and
latency belong to the Monitors widget, so this one doesn't read them."""
async with current_app.db_sessionmaker() as db:
hosts = (await db.execute(select(Host).order_by(Host.name))).scalars().all()
host_status = await host_svc.host_status(db, [h.id for h in hosts])
uptime = await host_svc.compute_uptime(db)
host_names = [h.name for h in hosts]
agent = await host_svc.agent_overview_by_host(
db, host_names, host_svc.agent_stale_after_seconds(current_app.config))
cpu_sparks = await host_svc.agent_cpu_sparks_by_host(db, host_names)
return await render_template(
"hosts/overview_widget.html",
hosts=hosts, host_status=host_status,
uptime=uptime, agent=agent, cpu_sparks=cpu_sparks,
)
db, [h.name for h in hosts], host_svc.agent_stale_after_seconds(current_app.config))
rows = host_svc.host_glance(hosts, host_status, agent, current_app.config.get("THRESHOLDS", {}))
return await render_template("hosts/overview_widget.html", rows=rows)
@hosts_bp.get("/")
+51 -29
View File
@@ -170,10 +170,13 @@ async def host_monitors(db, host_id: str) -> list[dict]:
async def agent_overview_by_host(
db, host_names: list[str], stale_after_seconds: int = 180,
) -> dict[str, dict]:
"""Per-host agent glance (cpu/mem/disk + freshness) from plugin_metric_latest.
"""Per-host agent glance (cpu/mem/disk/load + freshness) from plugin_metric_latest.
{host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root, load_1m,
last_at, fresh}} for each host with any agent samples.
cores, load_per_core, last_at, fresh}} for each host with any
agent samples. Cores are counted from the per-core CPU sub-resources
("<host>:core<N>"), as on the host page; load_per_core is load_1m as a %
of them, the figure Settings → Thresholds grades "load" by.
Core-safe (no host_agent import). Freshness compares the latest host-level
sample against the host_agent plugin's stale window (stale_after_seconds;
@@ -188,6 +191,16 @@ async def agent_overview_by_host(
latest = await latest_by_resource(
db, "host_agent", list(host_names) + [h + ":/" for h in host_names],
set(AGENT_GLANCE_METRICS) | {"disk_used_pct"})
from steward.models.metrics import PluginMetricLatest
cores: dict[str, int] = {}
for res in (await db.execute(
select(PluginMetricLatest.resource_name).where(
PluginMetricLatest.source_module == "host_agent",
PluginMetricLatest.metric_name == "cpu_pct",
PluginMetricLatest.resource_name.like("%:core%"))
)).scalars():
host = res.rsplit(":core", 1)[0]
cores[host] = cores.get(host, 0) + 1
out: dict[str, dict] = {}
for name in host_names:
own = {m: r for m, r in latest.get(name, {}).items() if m in AGENT_GLANCE_METRICS}
@@ -200,35 +213,44 @@ async def agent_overview_by_host(
ts = max((r.recorded_at for r in own.values()), default=None)
d["last_at"] = ts
d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after)
d["cores"] = cores.get(name)
load = d.get("load_1m")
d["load_per_core"] = round(load / d["cores"] * 100) if (load is not None and d["cores"]) else None
out[name] = d
return out
async def agent_cpu_sparks_by_host(db, host_names: list[str], hours: int = 1) -> dict[str, str]:
"""{host_name: inline-SVG cpu sparkline} over the last `hours` (core-safe).
GLANCE_ORDER = {"down": 0, "crit": 1, "stale": 2, "warn": 3, "up": 4, "unknown": 5}
A tiny at-a-glance trend in each row — the host-view sparkline, on the widget.
"""
from steward.models.metrics import PluginMetric
from steward.core.status import sparkline_svg
if not host_names:
return {}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
rows = (await db.execute(
select(PluginMetric.resource_name, PluginMetric.value, PluginMetric.recorded_at)
.where(
PluginMetric.source_module == "host_agent",
PluginMetric.metric_name == "cpu_pct",
PluginMetric.resource_name.in_(host_names),
PluginMetric.recorded_at >= cutoff,
)
.order_by(PluginMetric.recorded_at)
)).all()
by_host: dict[str, list[float]] = {}
for r in rows:
by_host.setdefault(r.resource_name, []).append(r.value)
# Amber line to read as "CPU"; only draw when there are a couple of points.
return {
name: sparkline_svg(vals[-40:], width=64, height=18, stroke="#c8a840")
for name, vals in by_host.items() if len(vals) >= 2
}
def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict]:
"""The Hosts — Overview rows, worst first (#5251).
Each is {host, state, worst, agent}. State is "down" when a linked monitor
is down, "stale" when the agent has stopped reporting, else the grade of
the reading furthest past its cutoff ("crit" / "warn": CPU, memory, disk /
or load per core, against Settings → Thresholds), else "up"; "unknown" for
a host with neither monitors nor agent. `worst` is (label, value, grade)
or None, from formatting.worst_reading."""
from steward.core.formatting import worst_reading
rows = []
for h in hosts:
st, a = status.get(h.id), agent.get(h.name)
worst = None
if a and a.get("fresh"):
worst = worst_reading((
("CPU", a.get("cpu_pct"), "cpu"), ("Memory", a.get("mem_used_pct"), "mem"),
("Disk /", a.get("disk_root"), "disk"), ("Load", a.get("load_per_core"), "load")),
thresholds)
if st and st["state"] == "down":
state = "down"
elif a and not a.get("fresh"):
state = "stale"
elif worst:
state = worst[2]
elif st or a:
state = "up"
else:
state = "unknown"
rows.append({"host": h, "state": state, "worst": worst, "agent": a or {}})
return sorted(rows, key=lambda r: (GLANCE_ORDER[r["state"]], r["host"].name.lower()))