feat(ansible): schedules read as sentences, each with its own page (#5332)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 59s
CI / unit (push) Failing after 1m22s
CI / publish (push) Skipped

- The Schedules tab: a verdict line (failing first, paused, next due),
  each schedule as one line (what runs where, how often, when next, how it
  last went); the row opens its page; Run now and Pause stay in the row.
- /ansible/schedules/new and /ansible/schedules/<id>: the form on classes,
  dry run beside the button, the playbook's variables rendered with the
  page; at the foot Delete behind the confirm dialog. ?source=&playbook=
  start a new one on a playbook (Schedule it on the playbook's page).
- A run started by a schedule links to that schedule's page.
- The run page's id guard allows the Run again link's ?from=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:51:15 -04:00
co-authored by Claude Opus 5.5
parent 72a3261582
commit 0c72892f58
11 changed files with 348 additions and 211 deletions
+3 -2
View File
@@ -83,7 +83,7 @@ async def test_a_playbook_page_runs_it_and_shows_its_history(app):
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)}}]))
"next": {"id": "s1", "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">'):]
@@ -93,7 +93,8 @@ async def test_a_playbook_page_runs_it_and_shows_its_history(app):
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/schedules/s1"' in main
assert 'href="/ansible/schedules/new?source=steward-builtin&amp;playbook=maintenance/prune.yml"' 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
+6 -5
View File
@@ -102,14 +102,14 @@ async def test_the_runs_page_opens_on_its_verdict(app):
now = datetime.now(timezone.utc)
failed = run_view(_run("ffff0000", AnsibleRunStatus.failed, 90, took_s=5), {"steward:target:t1": "target web-1"}, now)
calm = {"days": 30, "total": 33, "failed": 0, "running": 0, "latest_failed": None,
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2, minutes=5)}}
"next": {"id": "s1", "name": "Nightly prune", "at": now + timedelta(hours=2, minutes=5)}}
bad = {"days": 30, "total": 33, "failed": 2, "running": 1, "latest_failed": failed, "next": None}
async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer"
ok = await render_template("ansible/index.html", **_ctx([], calm))
worse = await render_template("ansible/index.html", **_ctx([], bad, f={"q": "x", "status": "", "origin": ""}))
assert '<span class="tone-ok">33 runs in the last 30 days, all succeeded</span>' in ok
assert 'next: <a href="/ansible/schedules">Nightly prune</a> <time' in ok and ">in 2h" in ok
assert 'next: <a href="/ansible/schedules/s1">Nightly prune</a> <time' in ok and ">in 2h" in ok
assert '<span class="tone-crit">2 of 33 runs failed</span> in the last 30 days' in worse
assert 'latest <a href="/ansible/runs/ffff0000">update.yml on web-1</a>' in worse
assert '<span class="tone-info">1 running now</span>' in worse
@@ -126,12 +126,12 @@ def test_the_next_schedule_is_the_soonest_enabled_one():
now = datetime.now(timezone.utc)
def sched(name, ran_min_ago, every_s, enabled=True):
return SimpleNamespace(name=name, enabled=enabled, interval_seconds=every_s,
return SimpleNamespace(id=f"id-{name}", name=name, enabled=enabled, interval_seconds=every_s,
last_run_at=now - timedelta(minutes=ran_min_ago) if ran_min_ago is not None else None)
daily, hourly = sched("daily", 60, 86400), sched("hourly", 30, 3600)
assert next_scheduled([daily, hourly], now)["name"] == "hourly"
assert next_scheduled([daily, sched("paused", 59, 3600, enabled=False)], now)["name"] == "daily"
assert next_scheduled([daily, sched("new", None, 3600)], now) == {"name": "new", "at": None} # due now
assert next_scheduled([daily, sched("new", None, 3600)], now) == {"id": "id-new", "name": "new", "at": None} # due now
assert next_scheduled([], now) is None
@@ -176,7 +176,8 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
assert '<span class="badge badge-warn">Interrupted</span>' in verdict and " in 10m" in verdict
assert 'by <a href="/ansible/schedules">schedule nightly</a>' in verdict
assert "/cancel" not in page # an interrupted run has nothing to cancel
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "")
# No run id as text: only in the links to the run and to run it again.
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "").replace("?from=dddd4444#", "")
# 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
+89
View File
@@ -0,0 +1,89 @@
"""Schedules read as sentences and each has its own page (#5332): the list's
row opens it, Run now and Pause stay in the row, and Delete is on the page
behind the confirm dialog."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from quart import render_template, session
from steward.models.ansible import AnsibleRunStatus
from tests.test_style_tokens import hex_colours
NOW = datetime.now(timezone.utc)
def _sched(sid="s1", name="Nightly prune", enabled=True, last_error=None, check=False):
return NS(id=sid, name=name, enabled=enabled, source_name="steward-builtin",
playbook_path="maintenance/docker_prune.yml", inventory_scope="steward:group:g1",
interval_seconds=86400, last_error=last_error, last_run_at=NOW - timedelta(hours=22),
last_run_id="r1", params={"check": True} if check else None)
def _last(status=AnsibleRunStatus.success):
return NS(id="r1", status=status, started_at=NOW - timedelta(hours=22))
async def test_the_schedules_list_reads_as_sentences(app):
ok, broken = _sched(), _sched("s2", "Weekly update", last_error="Playbook not found")
rows = [{"s": broken, "last_run": None, "scope_label": "group Docker Swarm", "next": None},
{"s": ok, "last_run": _last(), "scope_label": "group Docker Swarm",
"next": {"id": "s1", "name": "Nightly prune", "at": NOW + timedelta(hours=2)}}]
async with app.test_request_context("/ansible/schedules"):
session["user_role"] = "operator"
page = await render_template("ansible/schedules.html", rows=rows, failing=1, paused=0,
next=rows[1]["next"])
main = page[page.index("<main"):]
assert '<span class="tone-crit">1 of 2 failing</span>' in main
assert main.count('<tr class="row-link">') == 2 and 'href="/ansible/schedules/s1"' in main
assert "maintenance/docker_prune.yml</span> on group Docker Swarm" in main and "daily" in main
assert '<div class="cell-sub tone-crit" title="Playbook not found">Playbook not found' in main
assert main.count(">Run now</button>") == 2 and ">Pause</button>" in main
assert "/delete" not in main and 'href="/ansible/schedules/new"' in main # Delete is on the schedule's page
assert '<a href="/ansible/schedules" aria-current="page">Schedules</a>' in main
assert not hex_colours(main), hex_colours(main)
def _page_ctx(editing):
return {"editing": editing, "groups": [NS(id="g1", name="Docker Swarm")], "targets": [],
"source_data": [{"name": "steward-builtin", "playbooks": ["maintenance/docker_prune.yml"]}],
"sel_source": "steward-builtin", "sel_playbook": "maintenance/docker_prune.yml",
"sel_playbooks": ["maintenance/docker_prune.yml"], "interval_presets": [(86400, "Daily")],
"edit_variables": [], "edit_description": "", "edit_category": "", "edit_confirm": False,
"last_run": _last() if editing else None, "scope_label": "group Docker Swarm" if editing else None,
"next": {"id": "s1", "name": "Nightly prune", "at": NOW + timedelta(hours=2)} if editing else None}
async def test_a_schedule_page_says_what_it_does_and_deletes_behind_the_dialog(app):
async with app.test_request_context("/ansible/schedules/s1"):
session["user_role"] = "operator"
page = await render_template("ansible/schedule.html", **_page_ctx(_sched(check=True)))
main = page[page.index("<main"):]
assert 'href="/ansible/schedules"' in page # crumbs back to the list
verdict = main[main.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
assert 'href="/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"' in verdict
assert "on group Docker Swarm · daily" in verdict and "dry run" in verdict and "next <time" in verdict
assert 'last: <a href="/ansible/runs/r1"><span class="badge badge-ok">Succeeded</span></a>' in verdict
assert 'action="/ansible/schedules/s1"' in main and 'value="Nightly prune"' in main
assert 'name="check" checked' in main
assert 'data-confirm="Delete schedule Nightly prune?' in main and 'action="/ansible/schedules/s1/delete"' in main
assert ">Run now</button>" in main
assert 'style="' not in main[main.index('id="form"'):] # the form on classes
assert not hex_colours(main), hex_colours(main)
async def test_a_new_schedule_starts_on_the_chosen_playbook(app):
async with app.test_request_context("/ansible/schedules/new"):
session["user_role"] = "operator"
page = await render_template("ansible/schedule.html", **_page_ctx(None))
assert "<title>New schedule — Steward</title>" in page
assert 'action="/ansible/schedules"' in page and "/delete" not in page
assert '<option value="maintenance/docker_prune.yml" selected>' in page
async def test_a_viewer_reads_a_schedule_without_its_form(app):
async with app.test_request_context("/ansible/schedules/s1"):
session["user_role"] = "viewer"
page = await render_template("ansible/schedule.html", **_page_ctx(_sched()))
assert "on group Docker Swarm" in page and 'id="form"' not in page and "/delete" not in page
assert "Run now" not in page
+6 -4
View File
@@ -32,12 +32,14 @@ async def test_maintenance_offers_changes_only_to_those_who_can_make_them(app):
async def test_schedules_show_a_viewer_the_list_without_its_controls(app):
s = NS(id="s1", name="nightly", enabled=True, source_name="local", playbook_path="prune.yml",
inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None)
rows = [{"s": s, "last_run": None, "next_run": NOW + timedelta(hours=2), "scope_label": "all inventory targets"}]
inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None, params=None)
nxt = {"id": "s1", "name": "nightly", "at": NOW + timedelta(hours=2)}
rows = [{"s": s, "last_run": None, "next": nxt, "scope_label": "all inventory targets"}]
async with app.test_request_context("/ansible/schedules"):
session["user_role"] = "viewer"
page = await render_template("ansible/schedules.html", rows=rows, editing=None)
page = await render_template("ansible/schedules.html", rows=rows, next=nxt, failing=0, paused=0)
assert "nightly" in page and "every 2h" in page and "7200s" not in page
assert ">in 2h</time>" in page or ">in 1h 59m</time>" in page
for control in ("Run now", "Pause", "?edit=", "/delete", "New schedule"):
assert 'class="row-target" href="/ansible/schedules/s1"' in page # the row opens the schedule
for control in ("Run now", "Pause", "/delete", "New schedule"):
assert control not in page, control