feat(ansible): Inventory on one page, add behind dialogs, delete behind the confirm (#5333)
/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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/")
|
||||
|
||||
Reference in New Issue
Block a user