From a0d1c5f07c46e8db08e0c34bc6cf7509e8f15efa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 17 Jun 2026 11:10:46 -0400 Subject: [PATCH] feat(host_agent): sparklines + load/core + PSI on the host panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each at-a-glance metric (CPU, Memory, Disk /, Load) now shows a 6h sparkline (reused core.status.sparkline_svg + a recent-series query; disk uses the root mount sub-resource) so trend/consistency is visible, not just the instant. - Load is now normalized: "Load /core" = 1m load ÷ CPU cores as % (100% = run queue matches capacity), comparable across different hardware. Cores derived from the per-core CPU metrics already collected — no agent change. Raw load in the tooltip. - Added a "Pressure 10s" line: PSI cpu/mem/io (some, avg10), the hardware-independent saturation signal already collected by the agent. Scribe #898. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/host_agent/routes.py | 40 +++++++++++++++++++++++- plugins/host_agent/templates/panel.html | 41 ++++++++++++++++--------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index a52167e..199c552 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -11,7 +11,7 @@ import secrets from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session from steward.auth.middleware import require_role from steward.models.users import UserRole -from sqlalchemy import select, func, or_ +from sqlalchemy import select, func, or_, and_ from datetime import timedelta from steward.core.settings import public_base_url @@ -550,10 +550,47 @@ async def host_panel(host_id: str): target = (await db.execute(select(AnsibleTarget).where( AnsibleTarget.host_id == host_id))).scalar_one_or_none() + # Recent series for the at-a-glance sparklines (cpu/mem/load host-level, + # disk from the root mount sub-resource). + series_raw: dict[str, list[float]] = {"cpu": [], "mem": [], "disk": [], "load": []} + if reg: + since = datetime.now(timezone.utc) - timedelta(hours=6) + spark_rows = (await db.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.recorded_at >= since, + or_( + and_(PluginMetric.resource_name == host.name, + PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "load_1m"))), + and_(PluginMetric.resource_name == host.name + ":/", + PluginMetric.metric_name == "disk_used_pct"), + ), + ).order_by(PluginMetric.recorded_at) + )).scalars().all() + for r in spark_rows: + if r.resource_name == host.name: + key = {"cpu_pct": "cpu", "mem_used_pct": "mem", "load_1m": "load"}.get(r.metric_name) + if key: + series_raw[key].append(r.value) + else: + series_raw["disk"].append(r.value) + + from steward.core.status import sparkline_svg hostlvl = latest.get(host.name, {}) # At-a-glance disk = the root filesystem (what people actually care about), # not the "worst" mount which is opaque. Worst stays on the full-metrics page. disk_root = (latest.get(host.name + ":/", {}) or {}).get("disk_used_pct") + sparks = {k: sparkline_svg(v[-120:]) for k, v in series_raw.items()} + # Normalize load by core count so it's comparable across hardware (load/core + # as %). Cores derived from the per-core CPU sub-resources already collected. + cores = sum(1 for k in latest if k.startswith(host.name + ":core")) + load1 = hostlvl.get("load_1m") + load_per_core = round(load1 / cores * 100) if (cores and load1 is not None) else None + psi = { + "cpu": hostlvl.get("psi_cpu_some_avg10"), + "mem": hostlvl.get("psi_mem_some_avg10"), + "io": hostlvl.get("psi_io_some_avg10"), + } ls = reg.last_seen_at if reg else None # "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on # the registration row — that row is minted before the playbook runs, so a @@ -565,6 +602,7 @@ async def host_panel(host_id: str): return await render_template( "panel.html", host=host, reg=reg, hostlvl=hostlvl, disk_root=disk_root, + sparks=sparks, cores=cores, load1=load1, load_per_core=load_per_core, psi=psi, reporting=reporting, stale=stale, target=target, ansible_available=has_capability("ansible.run_playbook"), managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()), diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html index 0c7d866..33fcd8d 100644 --- a/plugins/host_agent/templates/panel.html +++ b/plugins/host_agent/templates/panel.html @@ -14,24 +14,37 @@ {% if reporting %} - {# ── Reporting: at-a-glance metrics + lifecycle ── #} -
- {% set cpu = hostlvl.get('cpu_pct') %} - {% set mem = hostlvl.get('mem_used_pct') %} - {% set load1 = hostlvl.get('load_1m') %} + {# ── Reporting: at-a-glance metrics (+ sparkline trend) + lifecycle ── #} + {% set cpu = hostlvl.get('cpu_pct') %} + {% set mem = hostlvl.get('mem_used_pct') %} + {% set lbl = "font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;" %} +
-
CPU
-
{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}
+
CPU
+
{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}
+ {{ sparks.cpu | safe }}
-
Memory
-
{{ '%.0f%%'|format(mem) if mem is not none else '—' }}
+
Memory
+
{{ '%.0f%%'|format(mem) if mem is not none else '—' }}
+ {{ sparks.mem | safe }}
-
Disk /
-
{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}
-
-
Load 1m
-
{{ '%.2f'|format(load1) if load1 is not none else '—' }}
+
Disk /
+
{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}
+ {{ sparks.disk | safe }} +
+
Load /core
+
{{ '%d%%'|format(load_per_core) if load_per_core is not none else ('%.2f'|format(load1) if load1 is not none else '—') }}
+ {{ sparks.load | safe }}
+ {% if psi.cpu is not none or psi.mem is not none or psi.io is not none %} +
+ Pressure 10s +  CPU {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }} + · Mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }} + · IO {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }} +
+ {% endif %}
Full metrics →