feat(ansible): Inventory on one page, add behind dialogs, delete behind the confirm (#5333)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 57s
CI / unit (push) Successful in 1m23s
CI / publish (push) Successful in 1m25s

/ansible/inventory/ shows every target (address, its linked Steward host,
its groups) and every group (members, the schedules aimed at it) under a
verdict line. The whole row opens the target or group. Add target and
Add group open small dialogs; a failed add comes back with its dialog
open and what was typed. The red x leaves the rows: Delete is on the
target's or group's page, behind the confirm dialog, saying which
schedules would be left with nothing to run on.

The two list pages (/inventory/targets, /inventory/groups) are gone
with no redirect (rule 122); their URLs keep only the Add POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:57:54 -04:00
co-authored by Claude Opus 5.5
parent 4acaa42a97
commit 7c346354f2
12 changed files with 519 additions and 249 deletions
@@ -1,56 +1,75 @@
{# ansible/inventory/target_detail.html — one target (#5333): what it is (its
address, the Steward host it is linked to, its groups, the schedules aimed
at it), its form (operators), and at the foot Delete behind the confirm
dialog. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Target: {{ target.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "/ansible/inventory/targets"), (target.name, "")]) }}{% endblock %}
{% block title %}{{ target.name }} — Inventory — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/"), (target.name, "")]) }}{% endblock %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{{ ansible_tabs("inventory") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
</div>
{{ page_header(target.name) }}
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Connection</h3>
<div class="form-group">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(Ansible hostname — used in plays as <code>hosts: name</code>)</span></label>
<input type="text" name="name" value="{{ target.name }}" required>
</div>
<div class="form-group">
<label>Address <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(becomes <code>ansible_host</code> in inventory)</span></label>
<input type="text" name="address" value="{{ target.address }}" required>
</div>
<div class="form-group">
<label>
Target Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — override group vars for this target only)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: ubuntu&#10;ansible_port: 22">{{ ansible_vars_yaml }}</textarea>
</div>
<p class="page-verdict">
<span class="mono">{{ target.address }}</span>
· {% if host %}Steward host <a href="/hosts/{{ host.id }}">{{ host.name }}</a>{% else %}<span class="tone-dim">not linked to a Steward host</span>{% endif %}
· {% if target.groups %}in {% for g in target.groups|sort(attribute='name') %}<a href="/ansible/inventory/groups/{{ g.id }}">{{ g.name }}</a>{{ ", " if not loop.last }}{% endfor %}{% else %}<span class="tone-warn">in no group</span>{% endif %}
{%- if used %} · scheduled by {% for s in used %}<a href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a>{{ ", " if not loop.last }}{% endfor %}{% endif %}
</p>
{% if not host %}<p class="field-hint">A target is linked from its host's page, in the Ansible section.</p>{% endif %}
{% if can_edit %}
<form method="post" action="/ansible/inventory/targets/{{ target.id }}" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="tgt-conn">
<h2 id="tgt-conn" class="section-title">Connection</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="tgt-name">Name</label>
<input class="input" id="tgt-name" type="text" name="name" value="{{ target.name }}" required>
<span class="field-hint">What plays call it: <code>hosts: {{ target.name }}</code>.</span>
</div>
<div class="field">
<label class="field-label" for="tgt-address">Address</label>
<input class="input mono" id="tgt-address" type="text" name="address" value="{{ target.address }}" required>
<span class="field-hint">Becomes <code>ansible_host</code>.</span>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Groups</h3>
{% if not all_groups %}
<p style="color:var(--text-muted);font-size:0.9rem;">No groups exist yet.
<a href="/ansible/inventory/groups">Create a group</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for grp in all_groups %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="group_ids" value="{{ grp.id }}"
{% if grp.id in member_group_ids %}checked{% endif %}>
<span>{{ grp.name }}</span>
</label>
{% endfor %}
</div>
{% endif %}
<div class="field">
<label class="field-label" for="tgt-vars">Variables</label>
<textarea class="input mono" id="tgt-vars" name="ansible_vars" rows="6"
placeholder="ansible_user: ubuntu&#10;ansible_port: 22">{{ ansible_vars_yaml }}</textarea>
<span class="field-hint">YAML. These override its groups' variables for this target only.</span>
</div>
</section>
<button type="submit" class="btn">Save Changes</button>
<section class="card form-section" aria-labelledby="tgt-groups">
<h2 id="tgt-groups" class="section-title">Groups</h2>
{% if not all_groups %}
<p class="tone-muted">No groups yet. <a href="/ansible/inventory/?add=group">Add a group</a> first.</p>
{% else %}
<div class="check-list">
{% for grp in all_groups %}
<label class="field-check">
<input type="checkbox" name="group_ids" value="{{ grp.id }}" {% if grp.id in member_group_ids %}checked{% endif %}>
<span>{{ grp.name }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</section>
<button type="submit" class="btn">Save</button>
</form>
<section class="danger-zone" aria-labelledby="tgt-delete">
<h2 id="tgt-delete" class="section-title">Delete this target</h2>
<p class="field-hint">{% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} run{{ 's' if used|length == 1 }} on it alone and would find nothing to run on.{% else %}It leaves its groups; its past runs stay in the Runs list.{% endif %}</p>
<form method="post" action="/ansible/inventory/targets/{{ target.id }}/delete"
data-confirm="Delete target {{ target.name }}? {% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} aimed at it would find nothing to run on.{% else %}It leaves its groups; its past runs stay in the list.{% endif %}">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete target</button>
</form>
</section>
{% endif %}
{% endblock %}