feat(hosts): true live vitals strip; agent panel becomes management-only
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:
@@ -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 %}
|
||||
@@ -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>
|
||||
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' %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user