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
+122
View File
@@ -0,0 +1,122 @@
"""Inventory on one page (#5333): every target with its address, its linked
Steward host and its groups, then every group with its members and the
schedules aimed at it. The row opens the target or group; Add is a dialog;
Delete is on the target's or group's page, behind the confirm dialog."""
from types import SimpleNamespace as NS
import pytest
from quart import render_template, session
from werkzeug.exceptions import MethodNotAllowed
from tests.test_style_tokens import hex_colours
def _world():
swarm = NS(id="g1", name="Docker Swarm", targets=[])
empty = NS(id="g2", name="Web", targets=[])
d2 = NS(id="t1", name="VDNT-Docker02", address="10.0.0.12", host_id="h1", groups=[swarm])
util = NS(id="t2", name="VDNT-Util01", address="10.0.0.20", host_id=None, groups=[])
swarm.targets = [d2]
return d2, util, swarm, empty
def _ctx(add=None):
d2, util, swarm, empty = _world()
return {"targets": [d2, util], "groups": [swarm, empty],
"hosts": {"h1": NS(id="h1", name="docker02")},
"users": {"steward:group:g1": [NS(id="s1", name="Nightly prune")]},
"verdict": {"targets": 2, "groups": 2, "unlinked": 1, "ungrouped": 1}, "add": add}
def test_inventory_is_one_page(app):
urls = app.url_map.bind("localhost")
assert urls.match("/ansible/inventory/")[0] == "ansible_inventory.inventory"
assert urls.match("/ansible/inventory/targets/t1")[0] == "ansible_inventory.target_detail"
assert urls.match("/ansible/inventory/groups/g1")[0] == "ansible_inventory.group_detail"
# The two list pages are gone (rule 122): their URLs only take the Add form's POST.
for gone in ("/ansible/inventory/targets", "/ansible/inventory/groups"):
with pytest.raises(MethodNotAllowed):
urls.match(gone, method="GET")
async def test_the_inventory_lists_targets_then_groups(app):
async with app.test_request_context("/ansible/inventory/"):
session["user_role"] = "operator"
page = await render_template("ansible/inventory/index.html", **_ctx())
main = page[page.index("<main"):]
verdict = main[main.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
assert "2 targets in 2 groups" in verdict and '<span class="tone-warn">1 in no group</span>' in verdict
assert "1 not linked to a Steward host" in verdict
# Each row opens its target or group; the host and schedule links sit above it.
assert main.count('<tr class="row-link">') == 4
assert '<a class="row-target" href="/ansible/inventory/targets/t1">VDNT-Docker02</a>' in main
assert '<a class="row-target" href="/ansible/inventory/groups/g1">Docker Swarm</a>' in main
assert '<a class="row-inner" href="/hosts/h1">docker02</a>' in main
assert '<a class="row-inner" href="/ansible/schedules/s1">Nightly prune</a>' in main
assert main.index("VDNT-Docker02") < main.index('id="inv-groups"') # targets first
# No red × in the rows: deleting is on the target's or group's page.
assert "/delete" not in main and "×" not in main
assert 'id="add-target"' in main and 'id="add-group"' in main and "showModal()" in main
assert '<a href="/ansible/inventory/" aria-current="page">Inventory</a>' in main
assert not hex_colours(main), hex_colours(main)
async def test_a_failed_add_reopens_its_dialog_with_what_was_typed(app):
add = {"kind": "target", "error": "There is already a target named web-1.",
"values": {"name": "web-1", "address": "10.0.0.9"}}
async with app.test_request_context("/ansible/inventory/targets", method="POST"):
session["user_role"] = "operator"
page = await render_template("ansible/inventory/index.html", **_ctx(add))
assert '<p class="field-error" role="alert">There is already a target named web-1.</p>' in page
assert 'value="10.0.0.9"' in page
assert "document.getElementById('add-target').showModal();" in page
async def test_a_viewer_reads_the_inventory_but_cannot_add(app):
async with app.test_request_context("/ansible/inventory/"):
session["user_role"] = "viewer"
page = await render_template("ansible/inventory/index.html", **_ctx())
assert "VDNT-Docker02" in page and 'id="add-target"' not in page and "Add target" not in page
async def test_a_target_page_says_what_it_is_and_deletes_behind_the_dialog(app):
d2, util, swarm, empty = _world()
async with app.test_request_context("/ansible/inventory/targets/t1"):
session["user_role"] = "operator"
page = await render_template(
"ansible/inventory/target_detail.html", target=d2, host=NS(id="h1", name="docker02"),
used=[], all_groups=[swarm, empty], member_group_ids={"g1"}, ansible_vars_yaml="")
main = page[page.index("<main"):]
assert 'Steward host <a href="/hosts/h1">docker02</a>' in main
assert 'in <a href="/ansible/inventory/groups/g1">Docker Swarm</a>' in main
assert 'href="/ansible/inventory/"' in page # the crumb back up
form = main[main.index('action="/ansible/inventory/targets/t1/delete"'):]
assert form.index('data-confirm="Delete target VDNT-Docker02?') < form.index(">Delete target</button>")
assert 'value="g1" checked' in main and 'value="g2" >' in main
assert not hex_colours(main), hex_colours(main)
async def test_a_group_page_names_its_members_and_what_runs_on_it(app):
d2, util, swarm, empty = _world()
async with app.test_request_context("/ansible/inventory/groups/g1"):
session["user_role"] = "operator"
page = await render_template(
"ansible/inventory/group_detail.html", group=swarm, used=[NS(id="s1", name="Nightly prune")],
all_targets=[d2, util], member_ids={"t1"}, ansible_vars_yaml="")
main = page[page.index("<main"):]
assert '1 member:' in main and 'href="/ansible/inventory/targets/t1">VDNT-Docker02</a>' in main
assert 'scheduled by <a href="/ansible/schedules/s1">Nightly prune</a>' in main
# Deleting a group a schedule runs on says so before it goes.
assert 'data-confirm="Delete group Docker Swarm? 1 schedule aimed at it would find nothing to run on."' in main
assert not hex_colours(main), hex_colours(main)
async def test_a_viewer_reads_a_target_but_gets_no_form(app):
d2, util, swarm, empty = _world()
async with app.test_request_context("/ansible/inventory/targets/t1"):
session["user_role"] = "viewer"
page = await render_template(
"ansible/inventory/target_detail.html", target=d2, host=None, used=[],
all_groups=[swarm], member_group_ids={"g1"}, ansible_vars_yaml="")
assert "not linked to a Steward host" in page and "<form method=\"post\" action=\"/ansible/inventory" not in page
+1 -1
View File
@@ -92,7 +92,7 @@ async def test_ansible_pages_share_one_strip_of_tabs(app):
page = await render_template("ansible/index.html", **_ctx([]))
nav = page[page.index('<nav class="section-tabs" aria-label="Ansible">'):]
nav = nav[:nav.index("</nav>")]
for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/targets"):
for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/"):
assert f'href="{href}"' in nav, href
assert nav.count('aria-current="page"') == 1 and '<a href="/ansible/" aria-current="page">Runs</a>' in nav
assert "No runs yet." in page and "No runs in the last 30 days" in page
+61 -1
View File
@@ -1,4 +1,5 @@
"""Integration: the Ansible Runs tab (#5329) against real Postgres. Its
"""Integration: the Ansible Runs tab (#5329) and the Inventory page (#5333)
against real Postgres. The Runs tab's
verdict counts the last 30 days; the filter bar narrows by playbook, status
and origin; an HTMX request from the bar gets the table alone, and the next
page as the reader scrolls keeps the filters. Requires STEWARD_DATABASE_URL."""
@@ -94,3 +95,62 @@ def test_the_runs_tab_counts_and_filters(app):
status, playbook = out["playbook"]
assert status == 200 and "The playbook" in playbook and 'id="run-form"' not in playbook # a viewer
assert out["missing"][0] == 404
def test_the_inventory_page_and_its_add_dialogs(app):
"""#5333: one Inventory page with each target's linked host and groups; a
failed add comes back to the page with its dialog open; a good add opens
the new target's page; Delete goes back to the Inventory page."""
from sqlalchemy import select
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.hosts import Host
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid, hid, gid, tid = (str(uuid.uuid4()) for _ in range(4))
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"inv-{tag}", email=f"inv-{tag}@test",
password_hash="x", role=UserRole.operator, is_active=True))
s.add(Host(id=hid, name=f"host-{tag}", address="10.9.8.1"))
async with s.begin():
grp = AnsibleGroup(id=gid, name=f"grp-{tag}", ansible_vars={})
s.add(grp)
s.add(AnsibleTarget(id=tid, name=f"tgt-{tag}", address="10.9.8.1", host_id=hid,
ansible_vars={}, groups=[grp]))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="operator", username=f"inv-{tag}",
_csrf="test-token", login_at=time.time())
csrf = {"X-CSRF-Token": "test-token"}
page = await client.get("/ansible/inventory/")
dup = await client.post("/ansible/inventory/targets", headers=csrf,
form={"name": f"tgt-{tag}", "address": "10.9.8.2"})
new = await client.post("/ansible/inventory/targets", headers=csrf,
form={"name": f"new-{tag}", "address": "10.9.8.3"})
async with app.db_sessionmaker() as s:
new_id = (await s.execute(select(AnsibleTarget.id).where(
AnsibleTarget.name == f"new-{tag}"))).scalar_one()
gone = await client.post(f"/ansible/inventory/targets/{new_id}/delete", headers=csrf)
out = {"page": (page.status_code, await page.get_data(as_text=True)),
"dup": (dup.status_code, await dup.get_data(as_text=True)),
"new": (new.status_code, new.headers.get("Location")), "new_id": new_id,
"gone": (gone.status_code, gone.headers.get("Location"))}
async with app.db_sessionmaker() as s:
async with s.begin():
for model, rid in ((AnsibleTarget, tid), (AnsibleGroup, gid), (Host, hid), (User, uid)):
await s.delete(await s.get(model, rid))
return tag, hid, tid, out
tag, hid, tid, out = asyncio.run(_go())
status, page = out["page"]
assert status == 200
assert f'<a class="row-target" href="/ansible/inventory/targets/{tid}">tgt-{tag}</a>' in page
assert f'<a class="row-inner" href="/hosts/{hid}">host-{tag}</a>' in page
assert f'<span class="chip">grp-{tag}</span>' in page
status, dup = out["dup"]
assert status == 400 and f"There is already a target named tgt-{tag}." in dup
assert "document.getElementById('add-target').showModal();" in dup and 'value="10.9.8.2"' in dup
assert out["new"] == (302, f"/ansible/inventory/targets/{out['new_id']}")
assert out["gone"] == (302, "/ansible/inventory/")