feat(ansible): schedules read as sentences, each with its own page (#5332)
- 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:
@@ -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&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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user