feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330)
- section_tabs macro; Runs · Playbooks · Schedules · Inventory on every Ansible page, replacing each page's own set of header buttons. - Runs: a verdict line (last 30 days, failures first with the latest named, running now, the schedule due next); under each status what the run did across its hosts (ansible/recap.py); a filter bar (playbook, status, who started it) that swaps the table and carries into the scroll paging. - Run page: status, duration, start and who started it in one line; each host's result in words, worst first; the playbook it ran links to it; the log and failures on classes rather than inline colours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,10 @@ from quart import render_template, session
|
||||
|
||||
from steward.ansible.inventory_gen import run_title
|
||||
from steward.ansible.origins import describe_origin
|
||||
from steward.ansible.routes import hosts_worst_first, run_duration, run_view
|
||||
from steward.ansible.recap import host_recap, run_recap
|
||||
from steward.ansible.routes import (
|
||||
RUN_ORIGINS, RUN_STATUSES, hosts_worst_first, next_scheduled, run_duration, run_filters, run_view,
|
||||
)
|
||||
from steward.models.ansible import AnsibleRunStatus
|
||||
from tests.test_style_tokens import hex_colours
|
||||
|
||||
@@ -24,14 +27,14 @@ def test_a_run_is_named_by_what_ran_where(label, title):
|
||||
assert run_title("update.yml", label) == title
|
||||
|
||||
|
||||
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False):
|
||||
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False, results=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
started = now - timedelta(minutes=started_min_ago)
|
||||
return SimpleNamespace(
|
||||
id=rid, playbook_path="update.yml", source_name="builtin", inventory_scope=scope,
|
||||
status=status, started_at=started,
|
||||
finished_at=started + timedelta(seconds=took_s) if took_s is not None else None,
|
||||
params={"check": True} if check else None, results=None, output="")
|
||||
params={"check": True} if check else None, results=results, output="")
|
||||
|
||||
|
||||
def test_a_running_run_counts_its_duration_so_far():
|
||||
@@ -41,56 +44,145 @@ def test_a_running_run_counts_its_duration_so_far():
|
||||
assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95
|
||||
|
||||
|
||||
def _ctx(runs, verdict=None, f=None, more_url=None):
|
||||
return {"runs": runs, "more_url": more_url, "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
|
||||
"f": f or {"q": "", "status": "", "origin": ""}, "filtered": bool(f),
|
||||
"verdict": verdict or {"days": 30, "total": 0, "failed": 0, "running": 0,
|
||||
"latest_failed": None, "next": None}}
|
||||
|
||||
|
||||
async def test_the_runs_list_names_each_run_with_one_badge(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
labels = {"steward:target:t1": "target web-1", "steward:all": "all inventory targets"}
|
||||
two = {"hosts": {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 1}}}
|
||||
runs = [run_view(r, labels, now) for r in (
|
||||
_run("aaaa1111", AnsibleRunStatus.running, 2),
|
||||
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True),
|
||||
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40),
|
||||
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True,
|
||||
results={"hosts": {"db-1": {"ok": 1, "failed": 1}, "web-1": {"ok": 2}}}),
|
||||
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40, results=two),
|
||||
)]
|
||||
more = "/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"
|
||||
async with app.test_request_context("/ansible/"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x")
|
||||
page = await render_template("ansible/index.html", **_ctx(runs, more_url=more))
|
||||
|
||||
main = page[page.index("<main"):]
|
||||
assert '<tr class="row-link">' in main # the whole row opens the run
|
||||
assert '<a class="row-target" href="/ansible/runs/aaaa1111">update.yml on web-1</a>' in main
|
||||
assert ('<a class="row-target" href="/ansible/runs/aaaa1111" title="update.yml on web-1">'
|
||||
'update.yml on web-1</a>') in main
|
||||
assert "steward:target:t1" not in main # no raw scope, not even as a tooltip
|
||||
assert ">update.yml on all inventory targets</a>" in main
|
||||
assert '<span class="badge badge-info">Running</span>' in main
|
||||
assert '<span class="badge badge-crit">Failed</span>' in main
|
||||
assert '<span class="badge badge-ok">Succeeded</span>' in main
|
||||
# Under each status, what the run did across its hosts (#5329).
|
||||
assert '<span class="tone-crit">failed on db-1</span> · 2 hosts' in main
|
||||
assert "2 hosts · 2 changed" in main
|
||||
# The list continues itself as the reader scrolls (#5211): no cap of 50.
|
||||
assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main
|
||||
assert f'data-scroll-more hx-get="{more}"' in main
|
||||
assert "2m 5s" in main and "40s" in main and " so far" in main
|
||||
assert "dry run" in main and "ago" in main
|
||||
assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
async def test_ansible_pages_share_one_strip_of_tabs(app):
|
||||
async with app.test_request_context("/ansible/"):
|
||||
session["user_role"] = "viewer"
|
||||
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"):
|
||||
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
|
||||
|
||||
|
||||
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)}}
|
||||
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 '<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
|
||||
assert "No runs match these filters." in worse and 'value="x"' in worse
|
||||
|
||||
|
||||
def test_the_run_filters_drop_what_they_do_not_know():
|
||||
assert run_filters({"q": " prune ", "status": "failed", "origin": "mcp"}) == \
|
||||
{"q": "prune", "status": "failed", "origin": "mcp"}
|
||||
assert run_filters({"status": "exploded", "origin": "aliens"}) == {"q": "", "status": "", "origin": ""}
|
||||
|
||||
|
||||
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,
|
||||
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([], now) is None
|
||||
|
||||
|
||||
def test_a_runs_recap_reads_in_words_worst_first():
|
||||
hosts = {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 0},
|
||||
"db-1": {"ok": 1, "failed": 1}, "nas": {"unreachable": 1}}
|
||||
assert run_recap({"hosts": hosts}) == [
|
||||
("failed on db-1", "crit"), ("nas unreachable", "crit"), ("4 hosts", None), ("1 changed", None)]
|
||||
assert run_recap({"hosts": {"a": {"ok": 2}}}) == [("1 host", None), ("nothing changed", "muted")]
|
||||
assert run_recap({"hosts": {"a": {"changed": 1}}}, check=True) == [("1 host", None), ("1 would change", None)]
|
||||
many = {h: {"failed": 1} for h in ("a", "b", "c", "d")}
|
||||
assert run_recap({"hosts": many})[0] == ("failed on a, b and 2 more", "crit")
|
||||
assert run_recap(None) == [] and run_recap({"hosts": {}}) == []
|
||||
|
||||
|
||||
def test_a_hosts_recap_leaves_out_zeros():
|
||||
assert host_recap({"ok": 3, "changed": 1, "unreachable": 0, "failed": 0, "skipped": 2}) == [
|
||||
("3 ok", None), ("1 changed", None), ("2 skipped", "muted")]
|
||||
assert host_recap({"ok": 1, "failed": 1, "unreachable": 1}) == [
|
||||
("unreachable", "crit"), ("1 failed", "crit"), ("1 ok", None)]
|
||||
assert host_recap({"changed": 2}, check=True) == [("2 would change", None)]
|
||||
|
||||
|
||||
async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600)
|
||||
hosts = hosts_worst_first({"hosts": {
|
||||
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
|
||||
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}})
|
||||
async with app.test_request_context("/ansible/runs/dddd4444"):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template(
|
||||
"ansible/run_detail.html", run=run, row=run_view(run, {"steward:target:t1": "target web-1"}, now),
|
||||
origin={"text": "schedule nightly", "url": "/ansible/schedules", "kind": "schedule"},
|
||||
hosts=hosts_worst_first({"hosts": {
|
||||
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
|
||||
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}}))
|
||||
hosts=[(h, host_recap(c)) for h, c in hosts])
|
||||
|
||||
assert "<title>update.yml on web-1 — Steward</title>" in page
|
||||
assert 'href="/ansible/"' in page # breadcrumb back to the runs
|
||||
assert '<span class="badge badge-warn">Interrupted</span>' in page
|
||||
assert "<dd>10m</dd>" in page
|
||||
# One verdict line (#5330): how it ended, how long, who started it.
|
||||
verdict = page[page.index('<p class="page-verdict">'):]
|
||||
verdict = verdict[:verdict.index("</p>")]
|
||||
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
|
||||
# #5212: who started it, by name; no run id in the page; the failed host first.
|
||||
assert '<dd><a href="/ansible/schedules">schedule nightly</a></dd>' in page
|
||||
assert "dddd4444" not in page.replace("/ansible/runs/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
|
||||
assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user