feat(hosts): Phase 1 — host detail hub page (unify host IA)
Make Hosts the front-and-center hub. A host now has a real detail page at /hosts/<id> that pulls its facets into one view, instead of management being scattered across a nav-less Host-Agents area and the edit form. - hosts: new GET /hosts/<id> detail route + hosts/detail.html. Shows the monitors summary (ping/DNS status + latency + uptime 24h/7d/30d), an Ansible section (linked target, link/create, run-playbook), and an embedded Agent panel. Hosts list name links here; ansible-link redirects here. - host_agent: GET /plugins/host_agent/panel/<host_id> — a self-contained HTMX fragment embedded into the core hub across the plugin boundary (core never imports plugin models). Shows live agent metrics + Update/Rotate/Remove when installed, or the provisioning path when not: inline "generate managed key" warning, a prompt to link an Ansible target first, then Provision (bootstrap password) / Install (managed key) tied to the host's target scope. Part of milestone 70 (Hosts hub). Phase 2+ will enrich the list, redirect the old fleet/settings pages, and re-taxonomize plugins into capabilities vs integrations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -532,6 +532,43 @@ def _new_token_pair() -> tuple[str, str]:
|
||||
return raw, _hash_token(raw)
|
||||
|
||||
|
||||
@host_agent_bp.get("/panel/<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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
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()
|
||||
|
||||
hostlvl = latest.get(host.name, {})
|
||||
ls = reg.last_seen_at if reg else None
|
||||
stale = bool(reg) and (
|
||||
ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds())
|
||||
|
||||
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||
return await render_template(
|
||||
"panel.html",
|
||||
host=host, reg=reg, hostlvl=hostlvl, stale=stale, target=target,
|
||||
ansible_available=has_capability("ansible.run_playbook"),
|
||||
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
|
||||
)
|
||||
|
||||
|
||||
@host_agent_bp.get("/settings/")
|
||||
@require_role(UserRole.admin)
|
||||
async def settings_list():
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{# Per-host agent panel — embedded into /hosts/<id> via HTMX. Self-contained. #}
|
||||
{% set scope = "steward:target:" ~ target.id if target else "" %}
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h3 class="section-title" style="margin-bottom:0;">Agent</h3>
|
||||
{% if reg %}
|
||||
<span style="font-size:0.78rem;color:{{ 'var(--yellow)' if stale else 'var(--green)' }};">
|
||||
{{ 'stale' if stale else 'reporting' }}{% if reg.agent_version %} · v{{ reg.agent_version }}{% endif %}
|
||||
{% if reg.last_seen_at %} · last seen {{ reg.last_seen_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if reg %}
|
||||
{# ── Installed: at-a-glance metrics + lifecycle ── #}
|
||||
<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:0.9rem;">
|
||||
{% set cpu = hostlvl.get('cpu_pct') %}
|
||||
{% set mem = hostlvl.get('mem_used_pct') %}
|
||||
{% set disk = hostlvl.get('disk_used_pct_worst') %}
|
||||
{% set load1 = hostlvl.get('load_1m') %}
|
||||
<div><div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">CPU</div>
|
||||
<div style="font-weight:600;">{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}</div></div>
|
||||
<div><div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Memory</div>
|
||||
<div style="font-weight:600;">{{ '%.0f%%'|format(mem) if mem is not none else '—' }}</div></div>
|
||||
<div><div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Disk (worst)</div>
|
||||
<div style="font-weight:600;">{{ '%.0f%%'|format(disk) if disk is not none else '—' }}</div></div>
|
||||
<div><div style="font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;">Load 1m</div>
|
||||
<div style="font-weight:600;">{{ '%.2f'|format(load1) if load1 is not none else '—' }}</div></div>
|
||||
</div>
|
||||
|
||||
<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' %}
|
||||
{% if ansible_available and target %}
|
||||
<form method="post" action="/plugins/host_agent/update" style="margin:0;">
|
||||
<input type="hidden" name="inventory_scope" value="{{ scope }}">
|
||||
<button type="submit" class="btn btn-sm" title="Refresh agent.py + restart (token preserved)">Update agent</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/plugins/host_agent/settings/{{ host.id }}/rotate-token" style="margin:0;"
|
||||
onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/settings/{{ host.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ansible_available and not target %}
|
||||
<p style="color:var(--text-dim);font-size:0.8rem;margin:0.6rem 0 0;">
|
||||
Link an Ansible target (above) to enable one-click agent updates.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{# ── Not installed: provisioning path ── #}
|
||||
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:0.75rem;">
|
||||
No agent installed. The agent reports CPU, memory, disk, network and more back to Steward.
|
||||
</p>
|
||||
|
||||
{% if session.user_role != 'admin' %}
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;">An admin can install the agent here.</p>
|
||||
|
||||
{% elif not ansible_available %}
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;">
|
||||
Ansible isn't available, so the agent can't be deployed from here. Install it manually with the
|
||||
<a href="/plugins/host_agent/settings/">curl install command</a>.
|
||||
</p>
|
||||
|
||||
{% elif not managed_key_set %}
|
||||
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
|
||||
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
|
||||
border-radius:6px;padding:0.7rem 0.9rem;display:flex;align-items:center;gap:0.8rem;flex-wrap:wrap;">
|
||||
<span style="color:var(--yellow);">⚠</span>
|
||||
<span style="font-size:0.84rem;flex:1;min-width:13rem;">No managed SSH key yet — Steward needs one to log into hosts.</span>
|
||||
<form method="post" action="/settings/ansible/generate-key" style="margin:0;">
|
||||
<input type="hidden" name="next" value="/hosts/{{ host.id }}">
|
||||
<button type="submit" class="btn btn-sm">Generate managed key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif not target %}
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;">
|
||||
Link or create an Ansible target for this host (in the Ansible section below) first — provisioning
|
||||
needs an SSH connection.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.75rem;">
|
||||
Deploys the agent to <code>{{ target.name }}</code> by running an Ansible playbook. Steward mints
|
||||
the host's API token automatically; you'll watch the run live.
|
||||
</p>
|
||||
{# Provision: brand-new host (creates steward account + key over a one-time password) #}
|
||||
<form method="post" action="/plugins/host_agent/provision"
|
||||
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:0.75rem;">
|
||||
<input type="hidden" name="inventory_scope" value="{{ scope }}">
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);width:100%;">Provision (first contact — fresh host):</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label>Bootstrap user</label>
|
||||
<input type="text" name="bootstrap_user" required placeholder="root" style="width:8rem;" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label>Bootstrap password</label>
|
||||
<input type="password" name="bootstrap_password" required style="width:10rem;" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Provision</button>
|
||||
</form>
|
||||
{# Install: host already has the steward account (managed key works) #}
|
||||
<form method="post" action="/plugins/host_agent/deploy" style="display:flex;gap:0.6rem;align-items:center;">
|
||||
<input type="hidden" name="inventory_scope" value="{{ scope }}">
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">Already provisioned?</span>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Install agent (managed key)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
Reference in New Issue
Block a user