feat(hosts): true live vitals strip; agent panel becomes management-only
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m0s

Realizes the chosen host-summary layout: a thin live vitals bar at the very top,
separate from the agent management panel (no more duplicated CPU/MEM/DISK/LOAD).

- New fragment _host_vitals.html + route /plugins/host_agent/vitals/<id>: compact
  CPU / Memory / Disk(/) / Load-per-core (threshold-coloured + sparkline) +
  Pressure + live/stale·version·last-seen. Polled every 15s; renders nothing
  until the agent reports.
- The metric computation (latest snapshot + 6h sparkline query + load/core + PSI)
  moves from host_panel into host_vitals. host_panel slims to management only
  (reg/target/reporting/stale/ansible) and no longer queries metrics; panel.html
  drops the gauge row + pressure block, keeping status + lifecycle actions.
- hosts/detail.html: vitals strip on top (full width, live), then a 2-col
  [Monitors | Agent] grid, Ansible full width below, docker fragment last.

UI only. Templates parse; plugin-template parse test covers the new fragment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-20 19:49:01 -04:00
parent a35c369dd4
commit b0d3e83bdd
4 changed files with 91 additions and 55 deletions
+40 -14
View File
@@ -646,18 +646,14 @@ def _new_token_pair() -> tuple[str, str]:
return raw, _hash_token(raw)
@host_agent_bp.get("/panel/<host_id>")
@host_agent_bp.get("/vitals/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host agent panel embedded into the core Hosts hub via HTMX.
async def host_vitals(host_id: str):
"""Compact live vitals strip (CPU/MEM/DISK/LOAD + pressure) for the Hosts hub.
Shows live agent metrics if the host reports, otherwise the provisioning
actions — tied to the host's linked Ansible target (the SSH connection).
Self-contained fragment (no base.html) so it can be hx-swapped in.
Self-contained fragment, polled by hosts/detail.html. Renders nothing until
the agent reports — the Agent panel below then carries provisioning.
"""
from steward.core.capabilities import has_capability
from steward.models.ansible_inventory import AnsibleTarget
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
@@ -666,8 +662,6 @@ async def host_panel(host_id: str):
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
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).
@@ -710,6 +704,40 @@ async def host_panel(host_id: str):
"mem": hostlvl.get("psi_mem_some_avg10"),
"io": hostlvl.get("psi_io_some_avg10"),
}
ls = reg.last_seen_at if reg else None
reporting = bool(reg) and ls is not None
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_vitals.html",
reg=reg, cpu=hostlvl.get("cpu_pct"), mem=hostlvl.get("mem_used_pct"),
disk_root=disk_root, load_per_core=load_per_core, sparks=sparks, psi=psi,
reporting=reporting, stale=stale,
)
@host_agent_bp.get("/panel/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host agent management panel embedded into the Hosts hub via HTMX.
Lifecycle actions (update / rotate / remove / re-provision) when the host
reports, otherwise provisioning — tied to the host's linked Ansible target.
The live vitals are a separate strip (host_vitals); this panel is management
only. Self-contained fragment (no base.html) so it can be hx-swapped in.
"""
from steward.core.capabilities import has_capability
from steward.models.ansible_inventory import AnsibleTarget
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
target = (await db.execute(select(AnsibleTarget).where(
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
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
@@ -720,9 +748,7 @@ async def host_panel(host_id: str):
ansible_cfg = current_app.config.get("ANSIBLE", {})
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,
host=host, reg=reg, 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()),
)
@@ -0,0 +1,35 @@
{# Host vitals strip — compact CPU/MEM/DISK/LOAD + pressure, live-polled into the
host hub. Renders nothing until the agent reports (the Agent panel below then
shows provisioning). #}
{% if reporting %}
{% set lbl = "font-size:0.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;" %}
{% macro vital(label, value, text, kind, spark) %}
<div style="min-width:88px;">
<div style="{{ lbl }}">{{ label }}</div>
<div style="font-size:1.5rem;font-weight:700;line-height:1.1;{{ threshold_style(value, kind) }}">{{ text }}</div>
<div style="margin-top:0.2rem;">{{ spark | safe }}</div>
</div>
{% endmacro %}
<div class="card" style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem;margin-bottom:1rem;">
{{ vital("CPU", cpu, ('%.0f%%'|format(cpu) if cpu is not none else '—'), 'cpu', sparks.cpu) }}
{{ vital("Memory", mem, ('%.0f%%'|format(mem) if mem is not none else '—'), 'mem', sparks.mem) }}
{{ vital("Disk /", disk_root, ('%.0f%%'|format(disk_root) if disk_root is not none else '—'), 'disk', sparks.disk) }}
{{ vital("Load /core", load_per_core, ('%d%%'|format(load_per_core) if load_per_core is not none else '—'), 'load', sparks.load) }}
<div style="margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:0.3rem;font-size:0.75rem;color:var(--text-muted);text-align:right;">
<span>
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
{% if reg and reg.agent_version %}<span style="margin-left:0.3rem;">v{{ reg.agent_version }}</span>{% endif %}
</span>
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
<span title="Pressure stall — % of the last 10s tasks waited for the resource">
<span style="color:var(--text-dim);text-transform:uppercase;font-size:0.66rem;letter-spacing:0.04em;">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 '—' }}
</span>
{% endif %}
{% if reg and reg.last_seen_at %}<span>seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC</span>{% endif %}
</div>
</div>
{% endif %}
+2 -33
View File
@@ -14,39 +14,8 @@
</div>
{% if reporting %}
{# ── 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;" %}
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.6rem;">
<div title="Average CPU utilization across all cores">
<div style="{{ lbl }}">CPU</div>
<div style="font-weight:600;{{ threshold_style(cpu, 'cpu') }}">{{ '%.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 style="{{ lbl }}">Memory</div>
<div style="font-weight:600;{{ threshold_style(mem, 'mem') }}">{{ '%.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 style="{{ lbl }}">Disk /</div>
<div style="font-weight:600;{{ threshold_style(disk_root, 'disk') }}">{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}</div>
{{ sparks.disk | safe }}</div>
{# Load /core: 100% = run queue matches CPU capacity, so warn 80 / crit 100. #}
<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="{{ lbl }}">Load /core</div>
<div style="font-weight:600;{{ threshold_style(load_per_core, 'load') }}">{{ '%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>
{% 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>
&nbsp;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 %}
{# Reporting: live vitals are shown by the vitals strip above; this panel is
lifecycle/management only. #}
<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>
{% if session.user_role == 'admin' %}
+14 -8
View File
@@ -26,12 +26,13 @@
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{# ── Agent vitals + lifecycle (host_agent fragment) — primary, full width on top ── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
<div id="hv-strip"
hx-get="/plugins/host_agent/vitals/{{ host.id }}"
hx-trigger="load, every 15s"
hx-swap="innerHTML"></div>
{# ── Monitors + Ansible, side by side on wide screens, stacked when narrow ──── #}
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:1rem;align-items:start;margin-bottom:1rem;">
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
@@ -124,8 +125,14 @@
{% endif %}
</div>{# Monitors card #}
{# ── Ansible ──────────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:0;">
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card" style="margin-bottom:0;"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
</div>{# Monitors + Agent grid #}
{# ── Ansible (full width) ─────────────────────────────────────────────────── #}
<div class="card">
<h3 class="section-title">Ansible</h3>
{% if linked_target %}
<div style="display:flex;align-items:center;gap:1rem;padding:0.6rem 0.75rem;background:var(--bg-elevated);border-radius:4px;margin-bottom:1rem;flex-wrap:wrap;">
@@ -211,7 +218,6 @@
</div>
{% endif %}
</div>{# Ansible card #}
</div>{# Monitors + Ansible grid #}
{# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #}
{% if "docker" in enabled_plugins %}