From 8bdf07f709bfa5ca99e47dfb4a134f75e07a0ba3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 16 Jun 2026 20:29:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(hosts):=20Phase=201=20=E2=80=94=20host=20d?= =?UTF-8?q?etail=20hub=20page=20(unify=20host=20IA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Hosts the front-and-center hub. A host now has a real detail page at /hosts/ 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/ 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/ — 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) --- plugins/host_agent/routes.py | 37 ++++++ plugins/host_agent/templates/panel.html | 117 ++++++++++++++++++ steward/hosts/routes.py | 36 +++++- steward/templates/hosts/detail.html | 156 ++++++++++++++++++++++++ steward/templates/hosts/list.html | 2 +- 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 plugins/host_agent/templates/panel.html create mode 100644 steward/templates/hosts/detail.html diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index da49482..1863141 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -532,6 +532,43 @@ def _new_token_pair() -> tuple[str, str]: return raw, _hash_token(raw) +@host_agent_bp.get("/panel/") +@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(): diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html new file mode 100644 index 0000000..6391d19 --- /dev/null +++ b/plugins/host_agent/templates/panel.html @@ -0,0 +1,117 @@ +{# Per-host agent panel — embedded into /hosts/ via HTMX. Self-contained. #} +{% set scope = "steward:target:" ~ target.id if target else "" %} +
+
+

Agent

+ {% if reg %} + + {{ '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 %} + + {% endif %} +
+ + {% if reg %} + {# ── Installed: at-a-glance metrics + lifecycle ── #} +
+ {% 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') %} +
CPU
+
{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}
+
Memory
+
{{ '%.0f%%'|format(mem) if mem is not none else '—' }}
+
Disk (worst)
+
{{ '%.0f%%'|format(disk) if disk is not none else '—' }}
+
Load 1m
+
{{ '%.2f'|format(load1) if load1 is not none else '—' }}
+
+ +
+ Full metrics → + {% if session.user_role == 'admin' %} + {% if ansible_available and target %} +
+ + +
+ {% endif %} +
+ +
+
+ +
+ {% endif %} +
+ {% if ansible_available and not target %} +

+ Link an Ansible target (above) to enable one-click agent updates. +

+ {% endif %} + + {% else %} + {# ── Not installed: provisioning path ── #} +

+ No agent installed. The agent reports CPU, memory, disk, network and more back to Steward. +

+ + {% if session.user_role != 'admin' %} +

An admin can install the agent here.

+ + {% elif not ansible_available %} +

+ Ansible isn't available, so the agent can't be deployed from here. Install it manually with the + curl install command. +

+ + {% elif not managed_key_set %} +
+ + No managed SSH key yet — Steward needs one to log into hosts. +
+ + +
+
+ + {% elif not target %} +

+ Link or create an Ansible target for this host (in the Ansible section below) first — provisioning + needs an SSH connection. +

+ + {% else %} +

+ Deploys the agent to {{ target.name }} by running an Ansible playbook. Steward mints + the host's API token automatically; you'll watch the run live. +

+ {# Provision: brand-new host (creates steward account + key over a one-time password) #} +
+ +
Provision (first contact — fresh host):
+
+ + +
+
+ + +
+ +
+ {# Install: host already has the steward account (managed key works) #} +
+ + Already provisioned? + +
+ {% endif %} + {% endif %} +
diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 56b66d1..c9a903e 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -115,6 +115,40 @@ async def list_hosts(): ) +@hosts_bp.get("/") +@require_role(UserRole.viewer) +async def host_detail(host_id: str): + """The host hub: monitors + agent (embedded fragment) + Ansible, one view.""" + from steward.models.ansible_inventory import AnsibleTarget + from sqlalchemy.orm import selectinload + + 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 "Not found", 404 + ping = (await db.execute( + select(PingResult).where(PingResult.host_id == host_id) + .order_by(PingResult.probed_at.desc()).limit(1))).scalar_one_or_none() + dns = (await db.execute( + select(DnsResult).where(DnsResult.host_id == host_id) + .order_by(DnsResult.resolved_at.desc()).limit(1))).scalar_one_or_none() + uptime = (await _compute_uptime(db)).get(host_id) + linked_target = (await db.execute( + select(AnsibleTarget).where(AnsibleTarget.host_id == host_id) + .options(selectinload(AnsibleTarget.groups)))).scalar_one_or_none() + linkable_targets = (await db.execute( + select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None)) + .order_by(AnsibleTarget.name))).scalars().all() + + return await render_template( + "hosts/detail.html", + host=host, ping=ping, dns=dns, uptime=uptime, + linked_target=linked_target, linkable_targets=linkable_targets, + ansible_sources=_ansible_source_names(), + ) + + @hosts_bp.get("/new") @require_role(UserRole.operator) async def new_host(): @@ -250,7 +284,7 @@ async def ansible_link(host_id: str): ) db.add(new_target) - return redirect(f"/hosts/{host_id}/edit") + return redirect(f"/hosts/{host_id}") @hosts_bp.post("//run-playbook") diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html new file mode 100644 index 0000000..81217c5 --- /dev/null +++ b/steward/templates/hosts/detail.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} +{% from "_macros.html" import crumbs %} +{% block title %}{{ host.name }} — Hosts — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %} +{% block content %} + +
+
+

{{ host.name }}

+
+ {{ host.address or "no address — resolves by name" }} +
+
+
+ Edit + {% if session.user_role == 'admin' %} +
+ +
+ {% endif %} +
+
+ +{# ── Monitors ─────────────────────────────────────────────────────────────── #} +
+
+

Monitors

+ Configure → +
+
+
+
Ping
+ {% if not host.ping_enabled %} +
off
+ {% elif ping is none %} +
no data yet
+ {% else %} +
+ {{ ping.status.value }} +
+ {% if ping.response_time_ms is not none %} +
{{ '%.0f'|format(ping.response_time_ms) }} ms
+ {% endif %} + {% endif %} +
+
+
DNS
+ {% if not host.dns_enabled %} +
off
+ {% elif dns is none %} +
no data yet
+ {% else %} +
+ {{ dns.status.value }} +
+ {% if dns.resolved_ip %} +
{{ dns.resolved_ip }}
+ {% endif %} + {% endif %} +
+
+
Uptime
+ {% if uptime %} +
+ 24h {{ uptime['24h'] if uptime['24h'] is not none else '—' }}{{ '%' if uptime['24h'] is not none }} + · 7d {{ uptime['7d'] if uptime['7d'] is not none else '—' }}{{ '%' if uptime['7d'] is not none }} + · 30d {{ uptime['30d'] if uptime['30d'] is not none else '—' }}{{ '%' if uptime['30d'] is not none }} +
+ {% else %} +
+ {% endif %} +
+
+
+ +{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #} +
+
Loading agent…
+
+ +{# ── Ansible ──────────────────────────────────────────────────────────────── #} +
+

Ansible

+ {% if linked_target %} +
+
+ {{ linked_target.name }} + {{ linked_target.address }} + {% if linked_target.groups %} +
+ {% for grp in linked_target.groups %} + {{ grp.name }} + {% endfor %} +
+ {% endif %} +
+ Edit target +
+ + +
+
+ + {% if ansible_sources %} +
+ Run a playbook against this host +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {% endif %} + {% else %} +

+ No Ansible target linked. A target gives this host an SSH connection so Steward can + run playbooks and provision the agent. +

+
+ {% if linkable_targets %} +
+ + + +
+ {% endif %} +
+ + +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index 1cc0815..5c3a2a8 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -31,7 +31,7 @@ {% set dns = latest_dns.get(host.id) %} {% set ut = uptime.get(host.id, {}) %} - {{ host.name }} + {{ host.name }} {{ host.address }} {{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}