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)
|
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/")
|
@host_agent_bp.get("/settings/")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def settings_list():
|
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>
|
||||||
+35
-1
@@ -115,6 +115,40 @@ async def list_hosts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hosts_bp.get("/<host_id>")
|
||||||
|
@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")
|
@hosts_bp.get("/new")
|
||||||
@require_role(UserRole.operator)
|
@require_role(UserRole.operator)
|
||||||
async def new_host():
|
async def new_host():
|
||||||
@@ -250,7 +284,7 @@ async def ansible_link(host_id: str):
|
|||||||
)
|
)
|
||||||
db.add(new_target)
|
db.add(new_target)
|
||||||
|
|
||||||
return redirect(f"/hosts/{host_id}/edit")
|
return redirect(f"/hosts/{host_id}")
|
||||||
|
|
||||||
|
|
||||||
@hosts_bp.post("/<host_id>/run-playbook")
|
@hosts_bp.post("/<host_id>/run-playbook")
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title" style="margin-bottom:0.2rem;">{{ host.name }}</h1>
|
||||||
|
<div style="color:var(--text-muted);font-size:0.9rem;font-family:ui-monospace,monospace;">
|
||||||
|
{{ host.address or "no address — resolves by name" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||||
|
{% if session.user_role == 'admin' %}
|
||||||
|
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
|
||||||
|
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
|
||||||
|
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Configure →</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:2rem;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Ping</div>
|
||||||
|
{% if not host.ping_enabled %}
|
||||||
|
<div style="color:var(--text-dim);">off</div>
|
||||||
|
{% elif ping is none %}
|
||||||
|
<div style="color:var(--text-dim);">no data yet</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color:{{ 'var(--green)' if ping.status.value == 'up' else 'var(--red)' }};font-weight:600;">
|
||||||
|
{{ ping.status.value }}
|
||||||
|
</div>
|
||||||
|
{% if ping.response_time_ms is not none %}
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-muted);">{{ '%.0f'|format(ping.response_time_ms) }} ms</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">DNS</div>
|
||||||
|
{% if not host.dns_enabled %}
|
||||||
|
<div style="color:var(--text-dim);">off</div>
|
||||||
|
{% elif dns is none %}
|
||||||
|
<div style="color:var(--text-dim);">no data yet</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color:{{ 'var(--green)' if dns.status.value == 'resolved' else 'var(--red)' }};font-weight:600;">
|
||||||
|
{{ dns.status.value }}
|
||||||
|
</div>
|
||||||
|
{% if dns.resolved_ip %}
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-muted);font-family:ui-monospace,monospace;">{{ dns.resolved_ip }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">Uptime</div>
|
||||||
|
{% if uptime %}
|
||||||
|
<div style="font-size:0.85rem;">
|
||||||
|
24h <strong>{{ uptime['24h'] if uptime['24h'] is not none else '—' }}{{ '%' if uptime['24h'] is not none }}</strong>
|
||||||
|
· 7d <strong>{{ uptime['7d'] if uptime['7d'] is not none else '—' }}{{ '%' if uptime['7d'] is not none }}</strong>
|
||||||
|
· 30d <strong>{{ uptime['30d'] if uptime['30d'] is not none else '—' }}{{ '%' if uptime['30d'] is not none }}</strong>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color:var(--text-dim);">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# ── Ansible ──────────────────────────────────────────────────────────────── #}
|
||||||
|
<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;">
|
||||||
|
<div style="flex:1;min-width:12rem;">
|
||||||
|
<strong>{{ linked_target.name }}</strong>
|
||||||
|
<span style="color:var(--text-muted);font-size:0.85rem;margin-left:0.5rem;">{{ linked_target.address }}</span>
|
||||||
|
{% if linked_target.groups %}
|
||||||
|
<div style="margin-top:0.25rem;">
|
||||||
|
{% for grp in linked_target.groups %}
|
||||||
|
<span style="font-size:0.78rem;background:var(--bg);border-radius:3px;padding:0.1em 0.4em;margin-right:0.2rem;color:var(--text-muted);">{{ grp.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit target</a>
|
||||||
|
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">
|
||||||
|
<input type="hidden" name="action" value="unlink">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if ansible_sources %}
|
||||||
|
<details>
|
||||||
|
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">Run a playbook against this host</summary>
|
||||||
|
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Source</label>
|
||||||
|
<select name="source_name" required>
|
||||||
|
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Playbook <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
|
||||||
|
<input type="text" name="playbook_path" placeholder="playbooks/site.yml" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
|
||||||
|
<textarea name="extra_vars" rows="2" style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||||
|
<input type="checkbox" name="check"> Dry-run (--check --diff)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm">Run playbook</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1rem;">
|
||||||
|
No Ansible target linked. A target gives this host an SSH connection so Steward can
|
||||||
|
run playbooks and provision the agent.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
{% if linkable_targets %}
|
||||||
|
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<input type="hidden" name="action" value="link">
|
||||||
|
<select name="target_id">
|
||||||
|
{% for tgt in linkable_targets %}<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm">Link</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
{% set dns = latest_dns.get(host.id) %}
|
{% set dns = latest_dns.get(host.id) %}
|
||||||
{% set ut = uptime.get(host.id, {}) %}
|
{% set ut = uptime.get(host.id, {}) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ host.name }}</td>
|
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
|
||||||
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
||||||
<td style="color:var(--text-muted);font-size:0.85rem;">
|
<td style="color:var(--text-muted);font-size:0.85rem;">
|
||||||
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
|
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user