Same .row-link / .row-target as the Alerts rules list (snippet #5320). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
6.7 KiB
Python
128 lines
6.7 KiB
Python
"""Ansible runs are named by what ran where, say how long ago and for how
|
|
long, and wear the one status badge every page uses (#5125)."""
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
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.models.ansible import AnsibleRunStatus
|
|
from tests.test_style_tokens import hex_colours
|
|
|
|
|
|
@pytest.mark.parametrize("label, title", [
|
|
("target web-1", "update.yml on web-1"),
|
|
("group Docker Swarm", "update.yml on group Docker Swarm"),
|
|
("all inventory targets", "update.yml on all inventory targets"),
|
|
("homelab/hosts.ini", "update.yml on homelab/hosts.ini"),
|
|
("", "update.yml"),
|
|
])
|
|
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):
|
|
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="")
|
|
|
|
|
|
def test_a_running_run_counts_its_duration_so_far():
|
|
now = datetime.now(timezone.utc)
|
|
run = _run("r", AnsibleRunStatus.running, 3)
|
|
assert run_duration(run, now) == pytest.approx(180, abs=2)
|
|
assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95
|
|
|
|
|
|
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"}
|
|
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),
|
|
)]
|
|
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")
|
|
|
|
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 "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
|
|
# 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 "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_run_detail_titles_the_run_and_keeps_the_trail(app):
|
|
now = datetime.now(timezone.utc)
|
|
run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600)
|
|
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}}}))
|
|
|
|
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
|
|
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", "")
|
|
assert page.index(">zulu<") < page.index(">alpha<")
|
|
main = page[page.index("<main"):]
|
|
assert not hex_colours(main), hex_colours(main)
|
|
|
|
|
|
async def test_run_started_is_on_the_palette(app):
|
|
run = _run("eeee5555", AnsibleRunStatus.running, 0)
|
|
async with app.test_request_context("/ansible/runs"):
|
|
page = await render_template("ansible/run_started.html", run=run, title="update.yml on web-1")
|
|
assert '<span class="badge badge-info">Running</span>' in page
|
|
assert "update.yml on web-1" in page and 'href="/ansible/runs/eeee5555"' in page
|
|
assert not hex_colours(page), hex_colours(page)
|
|
|
|
|
|
def _origin_of(triggered_by=None, origin=None, origin_id=None):
|
|
run = SimpleNamespace(triggered_by=triggered_by, origin=origin, origin_id=origin_id)
|
|
return describe_origin(run, {"u1": "alice"}, {"s1": "nightly"}, {"r1": "nas down"})
|
|
|
|
|
|
def test_a_run_says_who_or_what_started_it():
|
|
"""#5212: the runs list tells a schedule, an alert, an agent and a person apart."""
|
|
assert _origin_of("u1") == {"text": "alice", "url": None, "kind": "user"}
|
|
assert _origin_of(None, "schedule", "s1")["text"] == "schedule nightly"
|
|
assert _origin_of("u1", "schedule", "s1")["text"] == "schedule nightly (run now by alice)"
|
|
assert _origin_of(None, "alert", "r1") == {"text": "alert rule nas down",
|
|
"url": "/alerts/history?rule=r1", "kind": "alert"}
|
|
assert _origin_of("u1", "mcp")["text"] == "alice via MCP"
|
|
assert _origin_of(None, "schedule", "gone")["text"] == "a deleted schedule"
|
|
assert _origin_of("u-gone")["text"] == "a deleted user"
|
|
assert _origin_of()["text"] == "Steward" # automated, from before origins
|
|
|
|
|
|
def test_failed_hosts_come_first_then_changed_then_by_name():
|
|
hosts = {"c": {"changed": 2}, "b": {"failed": 1}, "a": {}, "d": {"unreachable": 1}}
|
|
assert [h for h, _ in hosts_worst_first({"hosts": hosts})] == ["b", "d", "c", "a"]
|
|
assert hosts_worst_first(None) == [] and hosts_worst_first({"hosts": None}) == []
|