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()),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user