diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 61663ef..14dcfe7 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -646,18 +646,14 @@ def _new_token_pair() -> tuple[str, str]: return raw, _hash_token(raw) -@host_agent_bp.get("/panel/") +@host_agent_bp.get("/vitals/") @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/") +@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()), ) diff --git a/plugins/host_agent/templates/_host_vitals.html b/plugins/host_agent/templates/_host_vitals.html new file mode 100644 index 0000000..eea0c37 --- /dev/null +++ b/plugins/host_agent/templates/_host_vitals.html @@ -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) %} +
+
{{ label }}
+
{{ text }}
+
{{ spark | safe }}
+
+{% endmacro %} +
+ {{ 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) }} + +
+ + {% if stale %}stale{% else %}live{% endif %} + {% if reg and reg.agent_version %}v{{ reg.agent_version }}{% endif %} + + {% 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 %} + {% if reg and reg.last_seen_at %}seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC{% endif %} +
+
+{% endif %} diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html index 3feb62e..12ba1cb 100644 --- a/plugins/host_agent/templates/panel.html +++ b/plugins/host_agent/templates/panel.html @@ -14,39 +14,8 @@ {% 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;" %} -
-
-
CPU
-
{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}
- {{ sparks.cpu | safe }}
-
-
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 '—' }}
- {{ sparks.disk | safe }}
- {# Load /core: 100% = run queue matches CPU capacity, so warn 80 / crit 100. #} -
-
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 %} - + {# Reporting: live vitals are shown by the vitals strip above; this panel is + lifecycle/management only. #}
Full metrics → {% if session.user_role == 'admin' %} diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index fee31ea..02cd301 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -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 ── #} -
-
Loading agent…
-
+{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #} +
-{# ── Monitors + Ansible, side by side on wide screens, stacked when narrow ──── #} +{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
{# ── Monitors ─────────────────────────────────────────────────────────────── #} @@ -124,8 +125,14 @@ {% endif %}
{# Monitors card #} -{# ── Ansible ──────────────────────────────────────────────────────────────── #} -
+{# ── Agent (host_agent management panel) ──────────────────────────────────── #} +
+
Loading agent…
+
+
{# Monitors + Agent grid #} + +{# ── Ansible (full width) ─────────────────────────────────────────────────── #} +

Ansible

{% if linked_target %}
@@ -211,7 +218,6 @@
{% endif %}
{# Ansible card #} -
{# Monitors + Ansible grid #} {# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #} {% if "docker" in enabled_plugins %}