- /ansible/playbooks lists every playbook across sources: what it says it does, its category and destructive flag, how it last ran; the whole row opens the playbook. A source with none is one quiet line. - /ansible/playbooks/<source>/<path>: the verdict (what it does, flagged destructive, last ran), the run form in place (dry run beside the button, options folded), the schedules that use it, its recent runs, the playbook itself folded. ?from=<run> fills the form from that run: Run again on a run's page. - /ansible/browse, its view page and the /run-form fragment are gone, not redirected (rule 122); every link points at the new pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+95
-80
@@ -161,66 +161,113 @@ def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict:
|
||||
"recap": run_recap(run.results, check)}
|
||||
|
||||
|
||||
@ansible_bp.get("/browse")
|
||||
@require_role(UserRole.viewer)
|
||||
async def browse():
|
||||
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
|
||||
def _source(name: str) -> dict | None:
|
||||
return next((s for s in _get_sources() if s["name"] == name), None)
|
||||
|
||||
all_sources = _get_sources()
|
||||
source_data = []
|
||||
for source in all_sources:
|
||||
playbooks = []
|
||||
for path in src_module.discover_playbooks(source["path"]):
|
||||
|
||||
async def last_runs(db, keys) -> dict[tuple[str, str], AnsibleRun]:
|
||||
"""{(source, playbook): its newest run} for the given playbooks."""
|
||||
keys = list(keys)
|
||||
if not keys:
|
||||
return {}
|
||||
newest = (select(AnsibleRun.source_name, AnsibleRun.playbook_path,
|
||||
func.max(AnsibleRun.started_at).label("at"))
|
||||
.group_by(AnsibleRun.source_name, AnsibleRun.playbook_path).subquery())
|
||||
rows = (await db.execute(select(AnsibleRun).join(newest, (
|
||||
(AnsibleRun.source_name == newest.c.source_name)
|
||||
& (AnsibleRun.playbook_path == newest.c.playbook_path)
|
||||
& (AnsibleRun.started_at == newest.c.at))))).scalars().all()
|
||||
wanted = set(keys)
|
||||
return {(r.source_name, r.playbook_path): r for r in rows
|
||||
if (r.source_name, r.playbook_path) in wanted}
|
||||
|
||||
|
||||
@ansible_bp.get("/playbooks")
|
||||
@require_role(UserRole.viewer)
|
||||
async def playbooks():
|
||||
"""The Playbooks tab (#5331): every playbook across sources in one list,
|
||||
each with what it says it does and how it last ran; a source with none
|
||||
is one quiet line under it."""
|
||||
rows, empty = [], []
|
||||
sources = _get_sources()
|
||||
for source in sources:
|
||||
paths = src_module.discover_playbooks(source["path"])
|
||||
if not paths:
|
||||
empty.append({**source, "editable": src_module.is_editable_source(source)})
|
||||
for path in paths:
|
||||
contents = src_module.read_playbook(source["path"], path)
|
||||
meta = (src_module.discover_playbook_meta(contents) if contents is not None
|
||||
else {"description": "", "category": ""})
|
||||
playbooks.append({
|
||||
"path": path,
|
||||
"category": meta["category"],
|
||||
"description": meta["description"],
|
||||
})
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
source_data.append({
|
||||
"source": source,
|
||||
"playbooks": playbooks,
|
||||
"inventories": inventories,
|
||||
"editable": src_module.is_editable_source(source),
|
||||
})
|
||||
|
||||
else {"description": "", "category": "", "confirm": False})
|
||||
rows.append({"source": source["name"], "path": path, **meta})
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = (await db.execute(
|
||||
select(AnsibleTarget).order_by(AnsibleTarget.name)
|
||||
)).scalars().all()
|
||||
groups = (await db.execute(
|
||||
select(AnsibleGroup).order_by(AnsibleGroup.name)
|
||||
)).scalars().all()
|
||||
|
||||
last = await last_runs(db, [(r["source"], r["path"]) for r in rows])
|
||||
for r in rows:
|
||||
r["last"] = last.get((r["source"], r["path"]))
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=source_data,
|
||||
targets=targets,
|
||||
groups=groups,
|
||||
)
|
||||
"ansible/playbooks.html", rows=rows, empty_sources=empty, sources=sources,
|
||||
can_add=bool(_editable_sources()) and session.get("user_role") == "admin")
|
||||
|
||||
|
||||
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
|
||||
@ansible_bp.get("/playbooks/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def view_playbook(source_name: str, playbook_path: str):
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
async def playbook_page(source_name: str, playbook_path: str):
|
||||
"""One playbook's home (#5331): what it does, the form that runs it, the
|
||||
schedules that use it and its recent runs, then the playbook itself.
|
||||
?from=<run id> fills the form from that run ("Run again")."""
|
||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||
|
||||
source = _source(source_name)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
meta = src_module.discover_playbook_meta(contents)
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
runs = (await db.execute(select(AnsibleRun).where(
|
||||
AnsibleRun.source_name == source_name, AnsibleRun.playbook_path == playbook_path)
|
||||
.order_by(AnsibleRun.started_at.desc()).limit(RECENT_RUNS))).scalars().all()
|
||||
scheds = (await db.execute(select(AnsibleSchedule).where(
|
||||
AnsibleSchedule.source_name == source_name, AnsibleSchedule.playbook_path == playbook_path)
|
||||
.order_by(AnsibleSchedule.name))).scalars().all()
|
||||
labels = await scope_labels(db, [r.inventory_scope for r in runs] + [s.inventory_scope for s in scheds])
|
||||
origins = await run_origins(db, runs)
|
||||
targets = (await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
|
||||
groups = (await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
|
||||
again = None
|
||||
if request.args.get("from"):
|
||||
again = (await db.execute(select(AnsibleRun).where(
|
||||
AnsibleRun.id == request.args["from"], AnsibleRun.source_name == source_name,
|
||||
AnsibleRun.playbook_path == playbook_path))).scalar_one_or_none()
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=[],
|
||||
view_source=source_name,
|
||||
view_path=playbook_path,
|
||||
view_contents=contents,
|
||||
view_editable=src_module.is_editable_source(source),
|
||||
)
|
||||
"ansible/playbook.html", source=source, source_name=source_name, playbook_path=playbook_path,
|
||||
contents=contents, description=meta["description"], category=meta["category"],
|
||||
confirm=meta["confirm"], variables=variables, editable=src_module.is_editable_source(source),
|
||||
runs=[{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
|
||||
schedules=[{"s": s, "scope_label": labels.get(s.inventory_scope, s.inventory_scope),
|
||||
"next": next_scheduled([s], now)} for s in scheds],
|
||||
targets=targets, groups=groups, inventories=src_module.discover_inventories(source["path"]),
|
||||
prefill=run_prefill(again, {v["name"] for v in variables}))
|
||||
|
||||
|
||||
RECENT_RUNS = 10
|
||||
|
||||
|
||||
def run_prefill(run, declared: set[str]) -> dict:
|
||||
"""The run form's starting values: blank, or those of an earlier run
|
||||
("Run again"). Declared variables fill their own fields; any other extra
|
||||
var goes back into the free-form box. Secret values were never stored."""
|
||||
if run is None:
|
||||
return {"scope": "", "values": {}, "extra": "", "limit": "", "tags": "", "check": False}
|
||||
params = run.params or {}
|
||||
extra = params.get("extra_vars_map") or {}
|
||||
return {"scope": run.inventory_scope,
|
||||
"values": {k: v for k, v in extra.items() if k in declared},
|
||||
"extra": "\n".join(f"{k}={v}" for k, v in extra.items() if k not in declared),
|
||||
"limit": params.get("limit") or "", "tags": params.get("tags") or "",
|
||||
"check": bool(params.get("check"))}
|
||||
|
||||
|
||||
def _caller_role() -> UserRole:
|
||||
@@ -331,38 +378,6 @@ async def playbook_vars():
|
||||
confirm=meta["confirm"], schedule=schedule)
|
||||
|
||||
|
||||
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.operator)
|
||||
async def run_form(source_name: str, playbook_path: str):
|
||||
"""HTMX fragment: the run form for one playbook, with a field per declared
|
||||
variable (vars/vars_prompt) discovered from the playbook itself."""
|
||||
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
|
||||
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
meta = src_module.discover_playbook_meta(contents)
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = (await db.execute(
|
||||
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
|
||||
groups = (await db.execute(
|
||||
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
|
||||
|
||||
return await render_template(
|
||||
"ansible/_run_form.html",
|
||||
source_name=source_name, playbook_path=playbook_path,
|
||||
variables=variables, description=meta["description"], category=meta["category"],
|
||||
confirm=meta["confirm"], targets=targets, groups=groups,
|
||||
inventories=inventories,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/runs")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_run():
|
||||
@@ -803,7 +818,7 @@ async def save_playbook():
|
||||
"ansible_playbook", f"{source_name}:{playbook_path}",
|
||||
{"source": source_name, "playbook": playbook_path, "bytes": len(content)})
|
||||
return redirect(url_for(
|
||||
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
|
||||
"ansible.playbook_page", source_name=source_name, playbook_path=playbook_path))
|
||||
|
||||
|
||||
@ansible_bp.post("/playbooks/delete")
|
||||
@@ -819,7 +834,7 @@ async def delete_playbook():
|
||||
return err, 400
|
||||
await _audit("ansible.playbook.deleted", "ansible_playbook", f"{source['name']}:{playbook_path}",
|
||||
{"source": source["name"], "playbook": playbook_path})
|
||||
return redirect(url_for("ansible.browse"))
|
||||
return redirect(url_for("ansible.playbooks"))
|
||||
|
||||
|
||||
_NEW_PLAYBOOK_TEMPLATE = """\
|
||||
|
||||
Reference in New Issue
Block a user