feat(ansible): Playbooks as one list, and a page per playbook that runs it (#5331, #5330)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m20s
CI / publish (push) Skipped

- /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:
2026-10-07 23:49:05 -04:00
co-authored by Claude Opus 5.5
parent a02f224f22
commit 72a3261582
17 changed files with 447 additions and 230 deletions
+95 -80
View File
@@ -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 = """\
+11
View File
@@ -375,6 +375,17 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; }
.run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; }
.run-log-status { margin: 0; padding: 0.5rem 1rem; }
/* A playbook's run form (ansible/_run_form.html): options folded away, the
dry-run choice beside the button. */
.run-more { margin: 0 0 0.75rem; }
.run-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.6rem; }
.field-check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: normal; color: var(--text-muted); }
.form-actions .field-check { margin: 0; }
.playbook-cell { max-width: 30rem; }
/* The playbook itself, folded under its page. */
.playbook-source > summary { cursor: pointer; margin: 0; }
.playbook-source[open] > summary { margin-bottom: 0.75rem; }
.card-flush-note { margin: 0; padding: 0.75rem 1rem; }
/* Monospaced data: ids, limits, tags, paths. */
.mono { font-family: var(--fs-font-mono); font-size: 0.85em; }
/* The host page's health header (_host_vitals.html). */
+12
View File
@@ -0,0 +1,12 @@
{# ansible/_macros.html — small phrasings the Ansible pages share. #}
{# A schedule's interval in words: "daily", "hourly", "every 6h". #}
{% macro interval_words(secs) -%}
{%- set named = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%}
{{ named.get(secs) or ("every " ~ (secs|duration)) }}
{%- endmacro %}
{# A run's or host's recap parts (ansible.recap) as one line. #}
{% macro recap_words(parts) -%}
{% for text, tone in parts %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}
{%- endmacro %}
+35 -40
View File
@@ -1,67 +1,62 @@
{# Run form for a single playbook — loaded via HTMX from /ansible/run-form/… #}
<div style="background:var(--bg-elevated);border-radius:6px;padding:1rem;border:1px solid var(--border-mid);">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<h4 class="section-title" style="margin-bottom:0;">Run <span style="font-family:ui-monospace,monospace;font-weight:normal;">{{ playbook_path }}</span></h4>
<a href="#" onclick="this.closest('div[id^=run-form-]').innerHTML='';return false;"
class="btn btn-ghost btn-sm">Cancel</a>
</div>
<form hx-post="/ansible/runs" hx-target="next .run-result" hx-swap="innerHTML">
{# ansible/_run_form.html — the form that runs one playbook, on its page
(#5331): where to run it, its declared variables, more options folded
away, and the dry-run choice beside the button. `prefill` holds an earlier
run's choices ("Run again"); the run starts in place, under the form. #}
<form id="run-form" class="run-form" hx-post="/ansible/runs" hx-target="#run-result" hx-swap="innerHTML">
<input type="hidden" name="source_name" value="{{ source_name }}">
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
<div class="form-group">
<label>Run against</label>
<select name="inventory_scope">
<label for="run-scope">Run against</label>
<select id="run-scope" name="inventory_scope">
{% if targets %}
<option value="steward:all">All targets ({{ targets | length }})</option>
<option value="steward:all"{% if prefill.scope == 'steward:all' %} selected{% endif %}>All targets ({{ targets | length }})</option>
{% if groups %}
<optgroup label="Group">
{% for grp in groups %}<option value="steward:group:{{ grp.id }}">Group: {{ grp.name }}</option>{% endfor %}
{% for grp in groups %}{% set v = "steward:group:" ~ grp.id %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>Group: {{ grp.name }}</option>{% endfor %}
</optgroup>
{% endif %}
<optgroup label="Single target">
{% for tgt in targets %}<option value="steward:target:{{ tgt.id }}">{{ tgt.name }}</option>{% endfor %}
{% for tgt in targets %}{% set v = "steward:target:" ~ tgt.id %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>{{ tgt.name }}</option>{% endfor %}
</optgroup>
{% endif %}
{% if inventories %}
<optgroup label="Repo inventory file">
{% for inv in inventories %}<option value="repo:{{ source_name }}:{{ inv }}">{{ source_name }}/{{ inv }}</option>{% endfor %}
<optgroup label="Inventory file in {{ source_name }}">
{% for inv in inventories %}{% set v = "repo:" ~ source_name ~ ":" ~ inv %}<option value="{{ v }}"{% if prefill.scope == v %} selected{% endif %}>{{ source_name }}/{{ inv }}</option>{% endfor %}
</optgroup>
{% endif %}
{% if not targets and not inventories %}
<option value="" disabled>No targets or inventory files — add targets at /ansible/inventory/targets</option>
<option value="" disabled selected>Nothing to run against yet: add a target under Inventory</option>
{% endif %}
</select>
</div>
{% include "ansible/_playbook_vars.html" %}
{% with description="", category="", values=prefill["values"] %}{% include "ansible/_playbook_vars.html" %}{% endwith %}
<details style="margin:0.25rem 0 0.75rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Advanced</summary>
<div class="form-group" style="margin-top:0.6rem;">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
<textarea name="extra_vars" rows="2" placeholder="custom_var=value"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
</div>
<details class="run-more"{% if prefill.extra or prefill.limit or prefill.tags %} open{% endif %}>
<summary>More options</summary>
<div class="form-group">
<label>Limit to host(s) <span style="color:var(--text-muted);font-weight:normal;">(--limit)</span></label>
<input type="text" name="limit" placeholder="web01,db*">
<label for="run-extra">Extra vars <span class="field-hint">one key=value per line</span></label>
<textarea id="run-extra" name="extra_vars" rows="2" class="mono" placeholder="custom_var=value">{{ prefill.extra }}</textarea>
</div>
<div class="form-group">
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(--tags)</span></label>
<input type="text" name="tags" placeholder="deploy,config">
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="check">
Dry-run (--check --diff) — preview changes without applying
</label>
<div class="form-pair">
<div class="form-group">
<label for="run-limit">Only these hosts <span class="field-hint">--limit</span></label>
<input id="run-limit" type="text" name="limit" value="{{ prefill.limit }}" placeholder="web01,db*">
</div>
<div class="form-group">
<label for="run-tags">Only these tags <span class="field-hint">--tags</span></label>
<input id="run-tags" type="text" name="tags" value="{{ prefill.tags }}" placeholder="deploy,config">
</div>
</div>
</details>
<div style="display:flex;gap:0.75rem;align-items:center;">
<button type="submit" class="btn">Execute</button>
<div class="form-actions">
<button type="submit" class="btn">Run</button>
<label class="field-check">
<input type="checkbox" name="check"{% if prefill.check %} checked{% endif %}>
Dry run: show what would change, change nothing
</label>
</div>
</form>
<div class="run-result" style="margin-top:1rem;"></div>
</div>
</form>
<div id="run-result"></div>
+3 -2
View File
@@ -1,7 +1,8 @@
{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the
next page as the reader nears it (core.paging, pref 172). The whole row
opens the run (.row-link); its title is the one real link. Under the status,
what the run did across its hosts (ansible.recap.run_recap). #}
what the run did across its hosts (ansible.recap.run_recap). `compact` (a
playbook's recent runs) ends without the list's end marker. #}
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for row in runs %}
{% set run = row.run %}
@@ -23,6 +24,6 @@
{% endfor %}
{% if more_url %}
{{ scroll_more(more_url, cols=5) }}
{% else %}
{% elif not compact %}
{{ scroll_end(cols=5) }}
{% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% if not runs %}
<div class="card">
{%- if filtered %}{{ empty_state("No runs match these filters.", "Show every run", "/ansible/") }}
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/browse") }}{% endif -%}
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/playbooks") }}{% endif -%}
</div>
{% else %}
<div class="card-flush">
+1 -1
View File
@@ -2,7 +2,7 @@
on every Ansible page; `active` names the page or the one it sits under. #}
{% from "_macros.html" import section_tabs %}
{% macro ansible_tabs(active) -%}
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/browse"),
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/playbooks"),
("schedules", "Schedules", "/ansible/schedules"),
("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }}
{%- endmacro %}
-98
View File
@@ -1,98 +0,0 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Browse Playbooks — Steward{% endblock %}
{% block breadcrumb %}
{%- if view_contents is defined -%}
{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (view_path, "")]) }}
{%- else -%}
{{ crumbs([("Ansible", "/ansible/"), ("Browse", "")]) }}
{%- endif -%}
{% endblock %}
{% block content %}
{{ ansible_tabs("playbooks") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
<div style="display:flex;gap:0.5rem;">
{% if session.user_role == 'admin' %}
<a href="/ansible/playbooks/new" class="btn btn-sm">New playbook</a>
{% endif %}
</div>
</div>
{% if view_contents is defined %}
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h3 class="section-title">{{ view_path }}</h3>
<div style="display:flex;gap:0.5rem;">
{% if view_editable and session.user_role == 'admin' %}
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
<form method="post" action="/ansible/playbooks/delete" style="display:inline;"
data-confirm="Delete {{ view_path }}?">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ view_source }}">
<input type="hidden" name="playbook_path" value="{{ view_path }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
</div>
</div>
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
</div>
{% endif %}
{% if not source_data %}
{% if view_contents is not defined %}
<div class="card">
<p style="color:var(--text-muted);">No Ansible sources configured. <a href="/settings/ansible/">Add sources in Settings → Ansible</a>.</p>
</div>
{% endif %}
{% else %}
{% for sd in source_data %}
<div class="card" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
<h3 class="section-title">{{ sd.source.name }}</h3>
<span style="color:var(--text-muted);font-size:0.8rem;background:var(--bg-elevated);padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
<span style="color:var(--text-dim);font-size:0.8rem;">{{ sd.source.path }}</span>
</div>
{% if not sd.playbooks %}
<p style="color:var(--text-dim);font-size:0.9rem;">No playbooks found at this path.</p>
{% else %}
<table class="table" style="margin-bottom:1rem;">
<thead>
<tr>
<th>Playbook</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>
{% for pb in sd.playbooks %}
<tr>
<td>
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
{% if pb.category %}<span style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-elevated);color:var(--text-dim);border-radius:3px;padding:0.05em 0.45em;">{{ pb.category }}</span>{% endif %}
<span style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb.path }}</span>
</div>
{% if pb.description %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.15rem;">{{ pb.description }}</div>{% endif %}
</td>
<td class="td-actions">
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
{% if sd.editable and session.user_role == 'admin' %}
<a href="/ansible/playbooks/edit/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
{% endif %}
<a hx-get="/ansible/run-form/{{ sd.source.name }}/{{ pb.path }}"
hx-target="#run-form-{{ sd.source.name }}" hx-swap="innerHTML"
style="cursor:pointer;" class="btn btn-sm btn-ghost">Run</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Run form (loaded on demand via HTMX from /ansible/run-form/<source>/<pb>) #}
<div id="run-form-{{ sd.source.name }}"></div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% endblock %}
+1 -1
View File
@@ -8,7 +8,7 @@
{% block content %}
{{ ansible_tabs("runs") }}
{% call page_header("Ansible Runs") %}
<a href="/ansible/browse" class="btn">Run a playbook</a>
<a href="/ansible/playbooks" class="btn">Run a playbook</a>
{% endcall %}
{% set v = verdict %}
+81
View File
@@ -0,0 +1,81 @@
{# ansible/playbook.html — one playbook's page (#5331): what it says it does
and how it last ran, the form that runs it (operators), the schedules that
use it, its recent runs, then the playbook itself, folded. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "ansible/_macros.html" import interval_words %}
{% block title %}{{ playbook_path }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (playbook_path, "")]) }}{% endblock %}
{% block content %}
{% set can_run = session.user_role in ('operator', 'admin') %}
{{ ansible_tabs("playbooks") }}
{% call page_header(playbook_path) %}
{% if editable and session.user_role == 'admin' %}
<a href="/ansible/playbooks/edit/{{ source_name }}/{{ playbook_path }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/playbooks/delete"
data-confirm="Delete {{ playbook_path }}? The file is removed from {{ source_name }}; its past runs stay in the list.">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ source_name }}">
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
{% endcall %}
<p class="page-verdict">
{%- if category %}<span class="chip">{{ category }}</span> {% endif -%}
{{ description or "It says nothing about what it does." }}
{%- if confirm %} · <span class="tone-warn">flagged destructive by its author: a real run asks you to confirm</span>{% endif %}
· from <span class="mono">{{ source_name }}</span>
{%- if runs %} · last ran {{ runs[0].run.started_at|ago }} {{ status_badge(runs[0].run.status) }}{% else %} · never run{% endif -%}
</p>
{% if can_run %}
<section class="card" aria-labelledby="pb-run">
<h2 id="pb-run" class="section-title">Run it</h2>
{% include "ansible/_run_form.html" %}
</section>
{% endif %}
<section class="card-flush" aria-labelledby="pb-schedules">
<h2 id="pb-schedules" class="table-caption">Schedules
{%- if schedules %} <span class="tone-muted">{{ schedules|length }}</span>{% endif %}
{%- if can_run %} <a class="field-hint" href="/ansible/schedules">Schedule it →</a>{% endif %}</h2>
{% if schedules %}
<table class="table">
<tbody>
{% for row in schedules %}{% set s = row.s %}
<tr class="row-link{% if not s.enabled %} row-off{% endif %}">
<td class="cell-name"><a class="row-target" href="/ansible/schedules?edit={{ s.id }}#form">{{ s.name }}</a></td>
<td>on {{ row.scope_label }} · {{ interval_words(s.interval_seconds) }}</td>
<td class="nowrap">{% if not s.enabled %}<span class="tone-dim">paused</span>
{%- elif row.next and row.next.at %}next {{ row.next.at|ago }}{% else %}due now{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="field-hint card-flush-note">No schedule runs it.</p>
{% endif %}
</section>
<section class="card-flush" aria-labelledby="pb-runs">
<h2 id="pb-runs" class="table-caption">Recent runs
{%- if runs %} <a class="field-hint" href="/ansible/?q={{ playbook_path|urlencode }}">All its runs →</a>{% endif %}</h2>
{% if runs %}
<table class="table">
<thead><tr><th>Run</th><th>Result</th><th>Started by</th><th>Started</th><th class="num">Took</th></tr></thead>
<tbody>
{% with compact=true, more_url=none %}{% include "ansible/_runs_page.html" %}{% endwith %}
</tbody>
</table>
{% else %}
<p class="field-hint card-flush-note">It has not been run yet.</p>
{% endif %}
</section>
<details class="card playbook-source">
<summary class="section-title">The playbook</summary>
<pre class="run-log">{{ contents }}</pre>
</details>
{% endblock %}
@@ -2,7 +2,7 @@
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}{{ "Edit" if editing else "New" }} Playbook — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("playbooks") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
@@ -50,7 +50,7 @@
</div>
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn">Save</button>
<a href="/ansible/browse" class="btn btn-ghost">Cancel</a>
<a href="{{ ('/ansible/playbooks/' ~ source_name ~ '/' ~ playbook_path) if editing else '/ansible/playbooks' }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
+57
View File
@@ -0,0 +1,57 @@
{# ansible/playbooks.html — the Playbooks tab (#5331): every playbook across
its sources in one list, what each says it does and how it last ran; the
whole row opens the playbook's page, where it is run. A source with no
playbooks is one quiet line under the list. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% block title %}Playbooks — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("playbooks") }}
{% call page_header("Playbooks") %}
{% if can_add %}<a href="/ansible/playbooks/new" class="btn">New playbook</a>{% endif %}
{% endcall %}
{% if not sources %}
<div class="card">{{ empty_state("No playbook sources are set up.", "Add one in Settings", "/settings/ansible/") }}</div>
{% else %}
{% set n_src = rows|map(attribute="source")|unique|list|length %}
{% set n_bad = rows|selectattr("confirm")|list|length %}
<p class="page-verdict">
{{- rows|length }} playbook{{ 's' if rows|length != 1 }}{% if n_src %} from {{ n_src }} source{{ 's' if n_src != 1 }}{% endif %}
{%- if n_bad %} · <span class="tone-warn">{{ n_bad }} flagged destructive by {{ 'their authors' if n_bad > 1 else 'its author' }}</span>{% endif -%}
</p>
{% if rows %}
<div class="card-flush">
<table class="table">
<thead><tr><th>Playbook</th><th>Source</th><th>Last run</th></tr></thead>
<tbody>
{% for r in rows %}
<tr class="row-link">
<td class="cell-name playbook-cell">
<a class="row-target mono" href="/ansible/playbooks/{{ r.source }}/{{ r.path }}" title="{{ r.path }}">{{ r.path }}</a>
{% if r.description %}<div class="cell-sub" title="{{ r.description }}">{{ r.description }}</div>{% endif %}
</td>
<td>
<span class="tone-muted">{{ r.source }}</span>
{%- if r.category %} <span class="chip">{{ r.category }}</span>{% endif %}
{%- if r.confirm %} {{ status_badge('warn', 'destructive', 'Its author flagged it as making significant or destructive changes: a real run asks for confirmation') }}{% endif %}
</td>
<td class="nowrap">
{%- if r.last %}{{ status_badge(r.last.status) }} <span class="tone-muted">{{ r.last.started_at|ago }}</span>
{%- else %}<span class="tone-dim">never run</span>{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% for s in empty_sources %}
<p class="field-hint">{{ s.name }} (<span class="mono">{{ s.path }}</span>) has no playbooks yet.
{%- if can_add and s.editable %} <a href="/ansible/playbooks/new">Write one</a>{% endif %}</p>
{% endfor %}
{% endif %}
{% endblock %}
+4 -1
View File
@@ -17,6 +17,9 @@
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
</form>
{% endif %}
{% if session.user_role in ("operator", "admin") %}
<a href="/ansible/playbooks/{{ run.source_name }}/{{ run.playbook_path }}?from={{ run.id }}#run-form" class="btn btn-sm btn-ghost">Run again</a>
{% endif %}
<a href="/ansible/runs/{{ run.id }}/log" class="btn btn-sm btn-ghost">Download log</a>
{% endcall %}
@@ -31,7 +34,7 @@
<div class="card">
<dl class="kv">
<dt>Playbook</dt><dd><a href="/ansible/browse/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
<dt>Playbook</dt><dd><a href="/ansible/playbooks/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
<dt>On</dt><dd>{{ row.scope_label }}</dd>
{% if run.params %}
{% if run.params.limit %}<dt>Limit</dt><dd class="mono">{{ run.params.limit }}</dd>{% endif %}
@@ -34,7 +34,7 @@
</div>
<div style="display:flex;gap:0.4rem;flex-shrink:0;align-items:center;">
<a href="/ansible/browse" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Browse</a>
<a href="/ansible/playbooks" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Playbooks</a>
{% if src.type == 'git' %}
<span id="ansible-sync-status-{{ idx }}"></span>
+128
View File
@@ -0,0 +1,128 @@
"""The Playbooks tab and a playbook's own page (#5331): one list across
sources whose row opens the playbook; the page carries the form that runs
it (filled from an earlier run for "Run again"), the schedules that use it
and its recent runs."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from quart import render_template, session
from steward.ansible.routes import run_prefill, run_view
from steward.models.ansible import AnsibleRunStatus
from tests.test_style_tokens import hex_colours
def _run(rid, status, mins_ago, params=None):
now = datetime.now(timezone.utc)
return SimpleNamespace(id=rid, playbook_path="maintenance/prune.yml", source_name="steward-builtin",
inventory_scope="steward:all", status=status,
started_at=now - timedelta(minutes=mins_ago),
finished_at=now - timedelta(minutes=mins_ago - 1), params=params,
results={"hosts": {"a": {"ok": 2, "changed": 1}}}, output="")
def test_playbook_urls_reach_their_own_routes(app):
"""The playbook page's path converter must not swallow the editor's URLs."""
urls = app.url_map.bind("localhost")
assert urls.match("/ansible/playbooks/steward-builtin/maintenance/prune.yml")[0] == "ansible.playbook_page"
assert urls.match("/ansible/playbooks/edit/steward-local/a/b.yml")[0] == "ansible.edit_playbook"
assert urls.match("/ansible/playbooks/new")[0] == "ansible.new_playbook"
assert urls.match("/ansible/playbooks")[0] == "ansible.playbooks"
import pytest
from werkzeug.exceptions import NotFound
with pytest.raises(NotFound):
urls.match("/ansible/browse") # moved, not redirected (rule 122)
async def test_the_playbooks_list_is_one_table_across_sources(app):
last = _run("r1", AnsibleRunStatus.success, 60)
rows = [{"source": "steward-builtin", "path": "maintenance/prune.yml", "description": "Reclaim disk",
"category": "maintenance", "confirm": True, "last": last},
{"source": "steward-builtin", "path": "host_agent/update.yml", "description": "Update the agent",
"category": "", "confirm": False, "last": None}]
empty = [{"name": "steward-local", "path": "/data/ansible/playbooks", "editable": True}]
async with app.test_request_context("/ansible/playbooks"):
session["user_role"] = "admin"
page = await render_template("ansible/playbooks.html", rows=rows, empty_sources=empty,
sources=[{"name": "steward-builtin"}, {"name": "steward-local"}],
can_add=True)
main = page[page.index("<main"):]
assert main.count('<tr class="row-link">') == 2
assert 'class="row-target mono" href="/ansible/playbooks/steward-builtin/maintenance/prune.yml"' in main
assert "2 playbooks from 1 source" in main and "1 flagged destructive by its author" in main
assert ">destructive</span>" in main and '<span class="chip">maintenance</span>' in main
assert '<span class="badge badge-ok">Succeeded</span>' in main and "never run" in main
assert "steward-local" in main and "has no playbooks yet." in main and 'href="/ansible/playbooks/new"' in main
assert '<a href="/ansible/playbooks" aria-current="page">Playbooks</a>' in main
assert "/run-form/" not in main # the form lives on the playbook's page
assert not hex_colours(main), hex_colours(main)
def _page_ctx(role_runs=True, prefill=None, schedules=None):
now = datetime.now(timezone.utc)
run = _run("r9", AnsibleRunStatus.failed, 30, params={"check": True})
return {
"source": {"name": "steward-builtin"}, "source_name": "steward-builtin",
"playbook_path": "maintenance/prune.yml", "contents": "- hosts: all\n",
"description": "Reclaim disk", "category": "maintenance", "confirm": True,
"variables": [{"name": "prune_volumes", "prompt": "", "default": "false", "required": False, "secret": False}],
"editable": False,
"runs": [{**run_view(run, {"steward:all": "all inventory targets"}, now),
"origin": {"text": "alice", "url": None, "kind": "user"}}] if role_runs else [],
"schedules": schedules or [], "targets": [SimpleNamespace(id="t1", name="web-1")],
"groups": [SimpleNamespace(id="g1", name="Docker Swarm")], "inventories": [],
"prefill": prefill or run_prefill(None, set()),
}
async def test_a_playbook_page_runs_it_and_shows_its_history(app):
now = datetime.now(timezone.utc)
sched = SimpleNamespace(id="s1", name="Nightly prune", enabled=True, interval_seconds=86400,
last_run_at=now - timedelta(hours=22), inventory_scope="steward:group:g1")
async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"):
session["user_role"] = "operator"
page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[
{"s": sched, "scope_label": "group Docker Swarm",
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2)}}]))
main = page[page.index("<main"):]
assert 'href="/ansible/playbooks"' in page # crumbs back to the list
verdict = main[main.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
assert "Reclaim disk" in verdict and "flagged destructive" in verdict and "last ran" in verdict
assert 'id="run-form"' in main and 'hx-post="/ansible/runs"' in main
assert 'name="confirmed"' in main # the destructive flag's gate
assert 'name="var__prune_volumes"' in main and 'name="check"' in main
assert "Nightly prune" in main and "on group Docker Swarm · daily" in main
assert 'href="/ansible/schedules?edit=s1#form"' in main
assert 'href="/ansible/runs/r9"' in main and "End of the list." not in main
assert 'href="/ansible/?q=maintenance/prune.yml"' in main
assert "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only
assert not hex_colours(main), hex_colours(main)
async def test_a_viewer_reads_the_playbook_but_gets_no_form(app):
async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"):
session["user_role"] = "viewer"
page = await render_template("ansible/playbook.html", **_page_ctx(role_runs=False))
assert 'id="run-form"' not in page and "never run" in page and "No schedule runs it." in page
def test_run_again_fills_the_form_from_the_earlier_run():
run = SimpleNamespace(inventory_scope="steward:group:g1", params={
"extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "tags": "a", "check": True})
assert run_prefill(run, {"prune_volumes"}) == {
"scope": "steward:group:g1", "values": {"prune_volumes": "true"}, "extra": "other=x",
"limit": "web*", "tags": "a", "check": True}
assert run_prefill(None, set())["scope"] == ""
async def test_the_form_comes_back_as_the_earlier_run_left_it(app):
prefill = run_prefill(SimpleNamespace(inventory_scope="steward:group:g1", params={
"extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "check": True}), {"prune_volumes"})
async with app.test_request_context("/ansible/playbooks/x"):
session["user_role"] = "operator"
page = await render_template("ansible/playbook.html", **_page_ctx(prefill=prefill))
assert '<option value="steward:group:g1" selected>Group: Docker Swarm</option>' in page
assert 'name="var__prune_volumes"' in page and 'value="true"' in page
assert ">other=x</textarea>" in page and 'value="web*"' in page
assert '<details class="run-more" open>' in page and 'name="check" checked' in page
+3 -2
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/browse", "/ansible/schedules", "/ansible/inventory/targets"):
for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/targets"):
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
@@ -180,7 +180,8 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
# Each host's result in words, the failed host first.
assert page.index(">zulu<") < page.index(">alpha<")
assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page
assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran
assert 'href="/ansible/playbooks/builtin/update.yml"' in page # the playbook it ran
assert 'href="/ansible/playbooks/builtin/update.yml?from=dddd4444#run-form">Run again</a>' in page
main = page[page.index("<main"):]
assert 'style="' not in main[:main.index('id="run-output"')] # no hand-set colours
assert not hex_colours(main), hex_colours(main)
+12 -1
View File
@@ -60,7 +60,12 @@ def test_the_runs_tab_counts_and_filters(app):
out = {"page": await get(f"/ansible/?q=it{tag}"),
"failed": await get(f"/ansible/?q=it{tag}&status=failed", headers={"HX-Request": "true"}),
"sched": await get(f"/ansible/?q=it{tag}&origin=schedule", headers={"HX-Request": "true"}),
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"})}
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"}),
# The Playbooks tab and a built-in playbook's page (#5331): their
# newest-run and schedule queries against Postgres.
"playbooks": await get("/ansible/playbooks"),
"playbook": await get("/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"),
"missing": await get("/ansible/playbooks/steward-builtin/nope.yml")}
async with app.db_sessionmaker() as s:
async with s.begin():
from sqlalchemy import delete
@@ -83,3 +88,9 @@ def test_the_runs_tab_counts_and_filters(app):
assert f"it{tag}/prune.yml" in sched and f"it{tag}/update.yml" not in sched
person = out["person"][1]
assert f"it{tag}/update.yml" in person and f"it{tag}/prune.yml" not in person
status, playbooks = out["playbooks"]
assert status == 200 and 'href="/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"' in playbooks
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