diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 27602f2..bb858ff 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -74,7 +74,7 @@
These actions run bundled Ansible playbooks to deploy and maintain the
- Steward monitoring agent on your inventory hosts.
+ Steward monitoring agent on your inventory hosts.
Steward generates each host's API token automatically and connects over SSH as the managed
steward account — you'll be dropped into the live Ansible run to watch it.
- No Ansible inventory targets yet. Add some under Ansible → Inventory. + No Ansible inventory targets yet. Add some under Ansible → Inventory.
{% endif %} diff --git a/steward/ansible/inventory_routes.py b/steward/ansible/inventory_routes.py index ea1d746..4b4bb8d 100644 --- a/steward/ansible/inventory_routes.py +++ b/steward/ansible/inventory_routes.py @@ -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/