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
+82 -38
View File
@@ -11,6 +11,8 @@ from steward.ansible import guards
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.hosts import Host
from steward.models.users import UserRole
inventory_bp = Blueprint("ansible_inventory", __name__, url_prefix="/ansible/inventory")
@@ -38,38 +40,84 @@ def _vars_refusal(new: dict, old: dict | None = None) -> str | None:
UserRole(session.get("user_role", "viewer")))
def _add(kind: str, error: str, values: dict) -> dict:
"""An Add dialog to reopen on the Inventory page: which, why, what was typed."""
return {"kind": kind, "error": error, "values": values}
async def _name_taken(model, name: str) -> bool:
async with current_app.db_sessionmaker() as db:
return (await db.execute(select(model.id).where(model.name == name))).first() is not None
# ---------------------------------------------------------------------------
# The Inventory tab (#5333): targets and groups on one page
# ---------------------------------------------------------------------------
async def _scope_users(db) -> dict[str, list[AnsibleSchedule]]:
"""{inventory scope: [schedule, …]}: which schedules run against a target
or group, so its page can say what deleting it would leave pointing at
nothing."""
out: dict[str, list[AnsibleSchedule]] = {}
for sch in (await db.execute(select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars():
out.setdefault(sch.inventory_scope, []).append(sch)
return out
@inventory_bp.get("/")
@require_role(UserRole.viewer)
async def inventory(add: dict | None = None, status: int = 200):
"""Every target with its address, the Steward host it is linked to and
its groups; then every group with its members. The row opens the target
or group, where it is edited and deleted. `add` reopens an Add dialog
with the error and what was typed (a failed create), or ?add=target|group
opens one empty."""
async with current_app.db_sessionmaker() as db:
targets = (await db.execute(
select(AnsibleTarget).options(selectinload(AnsibleTarget.groups))
.order_by(AnsibleTarget.name))).scalars().all()
groups = (await db.execute(
select(AnsibleGroup).options(selectinload(AnsibleGroup.targets))
.order_by(AnsibleGroup.name))).scalars().all()
host_ids = [t.host_id for t in targets if t.host_id]
hosts = {h.id: h for h in (await db.execute(
select(Host).where(Host.id.in_(host_ids)))).scalars()} if host_ids else {}
users = await _scope_users(db)
if add is None and request.args.get("add") in ("target", "group"):
add = {"kind": request.args["add"], "error": None, "values": {}}
verdict = {
"targets": len(targets), "groups": len(groups),
"unlinked": sum(1 for t in targets if t.host_id not in hosts),
"ungrouped": sum(1 for t in targets if not t.groups),
}
body = await render_template(
"ansible/inventory/index.html", targets=targets, groups=groups, hosts=hosts,
users=users, verdict=verdict, add=add)
return body, status
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
@inventory_bp.get("/groups")
@require_role(UserRole.viewer)
async def groups_list():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AnsibleGroup)
.options(selectinload(AnsibleGroup.targets))
.order_by(AnsibleGroup.name)
)
groups = result.scalars().all()
return await render_template("ansible/inventory/groups.html", groups=groups)
@inventory_bp.post("/groups")
@require_role(UserRole.operator)
async def groups_create():
form = await request.form
name = form.get("name", "").strip()
values = {"name": name}
if not name:
return "name is required", 400
return await inventory(_add("group", "A group needs a name.", values), 400)
try:
ansible_vars = _parse_ansible_vars(form.get("ansible_vars", ""))
except ValueError as exc:
return str(exc), 400
return await inventory(_add("group", str(exc), values), 400)
err = _vars_refusal(ansible_vars)
if err:
return err, 400
return await inventory(_add("group", err, values), 400)
if await _name_taken(AnsibleGroup, name):
return await inventory(_add("group", f"There is already a group named {name}.", values), 400)
group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars)
async with current_app.db_sessionmaker() as db:
@@ -77,7 +125,7 @@ async def groups_create():
db.add(group)
await _audit("ansible.group.created", "ansible_group", group.id,
{"name": name, "vars": sorted(ansible_vars)})
return redirect(url_for("ansible_inventory.groups_list"))
return redirect(url_for("ansible_inventory.group_detail", group_id=group.id))
@inventory_bp.get("/groups/<group_id>")
@@ -95,11 +143,13 @@ async def group_detail(group_id: str):
all_targets = (
await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name))
).scalars().all()
used = (await _scope_users(db)).get(f"steward:group:{group.id}", [])
member_ids = {t.id for t in group.targets}
ansible_vars_yaml = yaml.dump(group.ansible_vars) if group.ansible_vars else ""
return await render_template(
"ansible/inventory/group_detail.html",
group=group,
used=used,
all_targets=all_targets,
member_ids=member_ids,
ansible_vars_yaml=ansible_vars_yaml,
@@ -157,46 +207,36 @@ async def group_delete(group_id: str):
)
group = result.scalar_one_or_none()
if group is None:
return redirect(url_for("ansible_inventory.groups_list"))
return redirect(url_for("ansible_inventory.inventory"))
name = group.name
await db.delete(group)
await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name})
return redirect(url_for("ansible_inventory.groups_list"))
return redirect(url_for("ansible_inventory.inventory"))
# ---------------------------------------------------------------------------
# Targets
# ---------------------------------------------------------------------------
@inventory_bp.get("/targets")
@require_role(UserRole.viewer)
async def targets_list():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AnsibleTarget)
.options(selectinload(AnsibleTarget.groups))
.order_by(AnsibleTarget.name)
)
targets = result.scalars().all()
return await render_template("ansible/inventory/targets.html", targets=targets)
@inventory_bp.post("/targets")
@require_role(UserRole.operator)
async def targets_create():
form = await request.form
name = form.get("name", "").strip()
address = form.get("address", "").strip()
values = {"name": name, "address": address}
if not name or not address:
return "name and address are required", 400
return await inventory(_add("target", "A target needs a name and an address.", values), 400)
try:
ansible_vars = _parse_ansible_vars(form.get("ansible_vars", ""))
except ValueError as exc:
return str(exc), 400
return await inventory(_add("target", str(exc), values), 400)
err = _vars_refusal(ansible_vars)
if err:
return err, 400
return await inventory(_add("target", err, values), 400)
if await _name_taken(AnsibleTarget, name):
return await inventory(_add("target", f"There is already a target named {name}.", values), 400)
target = AnsibleTarget(
id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars
@@ -206,7 +246,7 @@ async def targets_create():
db.add(target)
await _audit("ansible.target.created", "ansible_target", target.id,
{"name": name, "address": address, "vars": sorted(ansible_vars)})
return redirect(url_for("ansible_inventory.targets_list"))
return redirect(url_for("ansible_inventory.target_detail", target_id=target.id))
@inventory_bp.get("/targets/<target_id>")
@@ -224,11 +264,15 @@ async def target_detail(target_id: str):
all_groups = (
await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))
).scalars().all()
host = await db.get(Host, target.host_id) if target.host_id else None
used = (await _scope_users(db)).get(f"steward:target:{target.id}", [])
member_group_ids = {g.id for g in target.groups}
ansible_vars_yaml = yaml.dump(target.ansible_vars) if target.ansible_vars else ""
return await render_template(
"ansible/inventory/target_detail.html",
target=target,
host=host,
used=used,
all_groups=all_groups,
member_group_ids=member_group_ids,
ansible_vars_yaml=ansible_vars_yaml,
@@ -287,8 +331,8 @@ async def target_delete(target_id: str):
)
target = result.scalar_one_or_none()
if target is None:
return redirect(url_for("ansible_inventory.targets_list"))
return redirect(url_for("ansible_inventory.inventory"))
name = target.name
await db.delete(target)
await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name})
return redirect(url_for("ansible_inventory.targets_list"))
return redirect(url_for("ansible_inventory.inventory"))
+4
View File
@@ -216,6 +216,8 @@ strong, b, th { font-weight: 500; }
.table tr.row-link { position: relative; cursor: pointer; }
.table tr.row-link .row-target::after { content: ""; position: absolute; inset: 0; }
.table tr.row-link .td-actions { position: relative; z-index: 1; cursor: auto; }
/* A second link inside a row that opens as a whole: it sits above the row's target. */
.table tr.row-link .row-inner { position: relative; z-index: 1; }
.table td.nowrap { white-space: nowrap; }
.cell-note { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; }
/* An alert episode's own transitions, folded under it (#5316). */
@@ -434,6 +436,8 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
/* A column of checkboxes: a target's groups, a group's members. */
.check-list { display: grid; gap: 0.4rem; }
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
.form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; }
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
+1 -1
View File
@@ -4,5 +4,5 @@
{% macro ansible_tabs(active) -%}
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/playbooks"),
("schedules", "Schedules", "/ansible/schedules"),
("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }}
("inventory", "Inventory", "/ansible/inventory/")], active, "Ansible") }}
{%- endmacro %}
@@ -1,53 +1,66 @@
{# ansible/inventory/group_detail.html — one group (#5333): its members and
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 %}Group: {{ group.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "/ansible/inventory/groups"), (group.name, "")]) }}{% endblock %}
{% block title %}{{ group.name }} — Inventory — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/"), (group.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;">Group: {{ group.name }}</h1>
</div>
{{ page_header(group.name) }}
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Settings</h3>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{ group.name }}" required>
</div>
<div class="form-group">
<label>
Group Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — applied to all members; per-target vars override these)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: deploy&#10;http_port: 80">{{ ansible_vars_yaml }}</textarea>
</div>
<p class="page-verdict">
{%- set members = group.targets|sort(attribute='name') %}
{%- if members %}{{ members|length }} member{{ 's' if members|length != 1 }}:
{% for t in members %}<a href="/ansible/inventory/targets/{{ t.id }}">{{ t.name }}</a>{{ ", " if not loop.last }}{% endfor %}
{%- else %}<span class="tone-warn">no members</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 can_edit %}
<form method="post" action="/ansible/inventory/groups/{{ group.id }}" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="grp-settings">
<h2 id="grp-settings" class="section-title">Group</h2>
<div class="field">
<label class="field-label" for="grp-name">Name</label>
<input class="input" id="grp-name" type="text" name="name" value="{{ group.name }}" required>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Members</h3>
{% if not all_targets %}
<p style="color:var(--text-muted);font-size:0.9rem;">No targets exist yet.
<a href="/ansible/inventory/targets">Add targets</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for tgt in all_targets %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="target_ids" value="{{ tgt.id }}"
{% if tgt.id in member_ids %}checked{% endif %}>
<span>{{ tgt.name }}</span>
<span style="color:var(--text-muted);font-size:0.82rem;">{{ tgt.address }}</span>
</label>
{% endfor %}
</div>
{% endif %}
<div class="field">
<label class="field-label" for="grp-vars">Variables</label>
<textarea class="input mono" id="grp-vars" name="ansible_vars" rows="6"
placeholder="ansible_user: deploy&#10;http_port: 80">{{ ansible_vars_yaml }}</textarea>
<span class="field-hint">YAML, applied to every member. A target's own variables override these.</span>
</div>
</section>
<button type="submit" class="btn">Save Changes</button>
<section class="card form-section" aria-labelledby="grp-members">
<h2 id="grp-members" class="section-title">Members</h2>
{% if not all_targets %}
<p class="tone-muted">No targets yet. <a href="/ansible/inventory/?add=target">Add a target</a> first.</p>
{% else %}
<div class="check-list">
{% for tgt in all_targets %}
<label class="field-check">
<input type="checkbox" name="target_ids" value="{{ tgt.id }}" {% if tgt.id in member_ids %}checked{% endif %}>
<span>{{ tgt.name }}</span> <span class="mono tone-dim">{{ tgt.address }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</section>
<button type="submit" class="btn">Save</button>
</form>
<section class="danger-zone" aria-labelledby="grp-delete">
<h2 id="grp-delete" class="section-title">Delete this group</h2>
<p class="field-hint">{% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} run{{ 's' if used|length == 1 }} on it and would find nothing to run on.{% else %}Its targets stay; only the grouping goes.{% endif %}</p>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}/delete"
data-confirm="Delete group {{ group.name }}? {% if used %}{{ used|length }} schedule{{ 's' if used|length != 1 }} aimed at it would find nothing to run on.{% else %}Its targets stay; only the grouping goes.{% endif %}">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete group</button>
</form>
</section>
{% endif %}
{% endblock %}
@@ -1,54 +0,0 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Inventory Groups — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "")]) }}{% endblock %}
{% block content %}
{{ 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;">Inventory Groups</h1>
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets</a>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Group</h3>
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<div class="form-group" style="flex:1;margin:0;">
<label>Name</label>
<input type="text" name="name" placeholder="webservers" required>
</div>
<button type="submit" class="btn">Create</button>
</form>
</div>
{% if not groups %}
<div class="card">
<p style="color:var(--text-muted);">No groups yet. Create one above.</p>
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Name</th><th>Members</th><th></th></tr>
</thead>
<tbody>
{% for grp in groups %}
<tr>
<td><strong>{{ grp.name }}</strong></td>
<td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td>
<td class="td-actions">
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete group {{ grp.name }}?">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,125 @@
{# ansible/inventory/index.html — the Inventory tab (#5333): a verdict line,
every target (address, the Steward host it is linked to, its groups), then
every group (its members, the schedules that run on it). The whole row opens
the target or group, where it is edited and deleted. Add target and Add
group open small dialogs; a failed add reopens its dialog with the error. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, empty_state %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% block title %}Ansible Inventory — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "")]) }}{% endblock %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{{ ansible_tabs("inventory") }}
{% call page_header("Inventory") %}
{% if can_edit %}
<button type="button" class="btn btn-ghost" onclick="document.getElementById('add-group').showModal()">Add group</button>
<button type="button" class="btn" onclick="document.getElementById('add-target').showModal()">Add target</button>
{% endif %}
{% endcall %}
{% if not targets and not groups %}
<div class="card">{{ empty_state("No targets yet. A target is a machine Ansible can reach: a name, an address, and the groups it is in.") }}</div>
{% else %}
<p class="page-verdict">
{{ verdict.targets }} target{{ 's' if verdict.targets != 1 }} in {{ verdict.groups }} group{{ 's' if verdict.groups != 1 }}
{%- if verdict.ungrouped %} · <span class="tone-warn">{{ verdict.ungrouped }} in no group</span>{% endif %}
{%- if verdict.unlinked %} · {{ verdict.unlinked }} not linked to a Steward host{% endif %}
</p>
<section class="card-flush" aria-labelledby="inv-targets">
<h2 id="inv-targets" class="table-caption">Targets</h2>
{% if targets %}
<table class="table">
<thead><tr><th>Target</th><th>Steward host</th><th>Groups</th></tr></thead>
<tbody>
{% for t in targets %}
{% set h = hosts.get(t.host_id) %}
<tr class="row-link">
<td class="cell-name">
<a class="row-target" href="/ansible/inventory/targets/{{ t.id }}">{{ t.name }}</a>
<div class="cell-sub mono">{{ t.address }}</div>
</td>
<td>{% if h %}<a class="row-inner" href="/hosts/{{ h.id }}">{{ h.name }}</a>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
<td>
{%- for g in t.groups %}<span class="chip">{{ g.name }}</span>{% else %}<span class="tone-warn">none</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="card-flush-note tone-muted">No targets yet.</p>
{% endif %}
</section>
<section class="card-flush" aria-labelledby="inv-groups">
<h2 id="inv-groups" class="table-caption">Groups</h2>
{% if groups %}
<table class="table">
<thead><tr><th>Group</th><th>Members</th><th>Schedules</th></tr></thead>
<tbody>
{% for g in groups %}
{% set used = users.get('steward:group:' ~ g.id, []) %}
<tr class="row-link">
<td class="cell-name"><a class="row-target" href="/ansible/inventory/groups/{{ g.id }}">{{ g.name }}</a></td>
<td>
{%- for t in g.targets|sort(attribute='name') %}<span class="chip">{{ t.name }}</span>{% else %}<span class="tone-dim">no members</span>{% endfor -%}
</td>
<td>
{%- for s in used %}<a class="row-inner" href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a>{{ ", " if not loop.last }}{% else %}<span class="tone-dim">—</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="card-flush-note tone-muted">No groups yet. A group is what a run or a schedule targets, and what the Hosts list filters by.</p>
{% endif %}
</section>
{% endif %}
{% if can_edit %}
{% set a = add or {} %}
{% set v = a.get('values') or {} %}
<dialog id="add-target" class="modal" aria-labelledby="add-target-title">
<h2 id="add-target-title" class="modal-title">Add target</h2>
{% if a.kind == 'target' and a.error %}<p class="field-error" role="alert">{{ a.error }}</p>{% endif %}
<form method="post" action="/ansible/inventory/targets">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="add-target-name">Name</label>
<input class="input" id="add-target-name" type="text" name="name" required placeholder="web-01"
value="{{ v.name if a.kind == 'target' else '' }}">
<span class="field-hint">What plays call it: <code>hosts: web-01</code>.</span>
</div>
<div class="field">
<label class="field-label" for="add-target-address">Address</label>
<input class="input mono" id="add-target-address" type="text" name="address" required placeholder="192.168.1.10"
value="{{ v.address if a.kind == 'target' else '' }}">
<span class="field-hint">An IP or DNS name. Groups, variables and the Steward host are set on its page next.</span>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-sm btn-ghost" onclick="this.closest('dialog').close()">Cancel</button>
<button type="submit" class="btn btn-sm">Add target</button>
</div>
</form>
</dialog>
<dialog id="add-group" class="modal" aria-labelledby="add-group-title">
<h2 id="add-group-title" class="modal-title">Add group</h2>
{% if a.kind == 'group' and a.error %}<p class="field-error" role="alert">{{ a.error }}</p>{% endif %}
<form method="post" action="/ansible/inventory/groups">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="add-group-name">Name</label>
<input class="input" id="add-group-name" type="text" name="name" required placeholder="webservers"
value="{{ v.name if a.kind == 'group' else '' }}">
<span class="field-hint">Members and variables are set on its page next.</span>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-sm btn-ghost" onclick="this.closest('dialog').close()">Cancel</button>
<button type="submit" class="btn btn-sm">Add group</button>
</div>
</form>
</dialog>
{% if a.kind in ('target', 'group') %}<script>document.getElementById('add-{{ a.kind }}').showModal();</script>{% endif %}
{% endif %}
{% endblock %}
@@ -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 %}
@@ -1,63 +0,0 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Inventory Targets — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "")]) }}{% endblock %}
{% block content %}
{{ 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;">Inventory Targets</h1>
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">Groups</a>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Target</h3>
<form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<div class="form-group" style="flex:1;margin:0;">
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(used as Ansible hostname)</span></label>
<input type="text" name="name" placeholder="webserver-01" required>
</div>
<div class="form-group" style="flex:1;margin:0;">
<label>Address <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(IP or DNS)</span></label>
<input type="text" name="address" placeholder="192.168.1.10" required>
</div>
<button type="submit" class="btn">Add</button>
</form>
</div>
{% if not targets %}
<div class="card">
<p style="color:var(--text-muted);">No targets yet. Add one above.</p>
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Name</th><th>Address</th><th>Groups</th><th></th></tr>
</thead>
<tbody>
{% for tgt in targets %}
<tr>
<td><strong>{{ tgt.name }}</strong></td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ tgt.address }}</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% for grp in tgt.groups %}
<span style="background:var(--bg-elevated);border-radius:3px;padding:0.1em 0.4em;margin-right:0.25rem;">{{ grp.name }}</span>
{% endfor %}
</td>
<td class="td-actions">
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete target {{ tgt.name }}?">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}