feat(host_agent): sparklines + load/core + PSI on the host panel
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import secrets
|
|||||||
from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session
|
from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from sqlalchemy import select, func, or_
|
from sqlalchemy import select, func, or_, and_
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from steward.core.settings import public_base_url
|
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(
|
target = (await db.execute(select(AnsibleTarget).where(
|
||||||
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
|
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, {})
|
hostlvl = latest.get(host.name, {})
|
||||||
# At-a-glance disk = the root filesystem (what people actually care about),
|
# 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.
|
# 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")
|
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
|
ls = reg.last_seen_at if reg else None
|
||||||
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
|
# "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
|
# 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(
|
return await render_template(
|
||||||
"panel.html",
|
"panel.html",
|
||||||
host=host, reg=reg, hostlvl=hostlvl, disk_root=disk_root,
|
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,
|
reporting=reporting, stale=stale, target=target,
|
||||||
ansible_available=has_capability("ansible.run_playbook"),
|
ansible_available=has_capability("ansible.run_playbook"),
|
||||||
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
|
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
|
||||||
|
|||||||
@@ -14,24 +14,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if reporting %}
|
{% if reporting %}
|
||||||
{# ── Reporting: at-a-glance metrics + lifecycle ── #}
|
{# ── Reporting: at-a-glance metrics (+ sparkline trend) + lifecycle ── #}
|
||||||
<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:0.9rem;">
|
{% set cpu = hostlvl.get('cpu_pct') %}
|
||||||
{% set cpu = hostlvl.get('cpu_pct') %}
|
{% set mem = hostlvl.get('mem_used_pct') %}
|
||||||
{% set mem = hostlvl.get('mem_used_pct') %}
|
{% set lbl = "font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;" %}
|
||||||
{% set load1 = hostlvl.get('load_1m') %}
|
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.6rem;">
|
||||||
<div title="Average CPU utilization across all cores">
|
<div title="Average CPU utilization across all cores">
|
||||||
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">CPU</div>
|
<div style="{{ lbl }}">CPU</div>
|
||||||
<div style="font-weight:600;">{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}</div></div>
|
<div style="font-weight:600;">{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}</div>
|
||||||
|
{{ sparks.cpu | safe }}</div>
|
||||||
<div title="RAM in use (total minus available; cache/buffers count as free)">
|
<div title="RAM in use (total minus available; cache/buffers count as free)">
|
||||||
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Memory</div>
|
<div style="{{ lbl }}">Memory</div>
|
||||||
<div style="font-weight:600;">{{ '%.0f%%'|format(mem) if mem is not none else '—' }}</div></div>
|
<div style="font-weight:600;">{{ '%.0f%%'|format(mem) if mem is not none else '—' }}</div>
|
||||||
|
{{ sparks.mem | safe }}</div>
|
||||||
<div title="Root filesystem (/) usage — see Full metrics for every mount">
|
<div title="Root filesystem (/) usage — see Full metrics for every mount">
|
||||||
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Disk /</div>
|
<div style="{{ lbl }}">Disk /</div>
|
||||||
<div style="font-weight:600;">{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}</div></div>
|
<div style="font-weight:600;">{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}</div>
|
||||||
<div title="System load average over 1 minute (runnable + waiting processes). Compare to CPU core count.">
|
{{ sparks.disk | safe }}</div>
|
||||||
<div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Load 1m</div>
|
<div title="1-minute load average ÷ {{ cores or '?' }} CPU cores (100% = run queue matches capacity). Raw 1m load: {{ '%.2f'|format(load1) if load1 is not none else '—' }}.">
|
||||||
<div style="font-weight:600;">{{ '%.2f'|format(load1) if load1 is not none else '—' }}</div></div>
|
<div style="{{ lbl }}">Load /core</div>
|
||||||
|
<div style="font-weight:600;">{{ '%d%%'|format(load_per_core) if load_per_core is not none else ('%.2f'|format(load1) if load1 is not none else '—') }}</div>
|
||||||
|
{{ sparks.load | safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:0.9rem;"
|
||||||
|
title="Pressure Stall Information — % of the last 10s that tasks were stalled waiting for the resource. Hardware-independent, comparable across hosts.">
|
||||||
|
<span style="text-transform:uppercase;font-size:0.7rem;letter-spacing:0.04em;color:var(--text-dim);">Pressure 10s</span>
|
||||||
|
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 '—' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
||||||
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user