feat(ui): each run says who or what started it; run detail without ids (#5212)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 49s
CI / unit (push) Successful in 1m15s
CI / publish (push) Successful in 1m3s

#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 2:

- A run records where it came from: AnsibleRun.origin ("schedule", "alert"
  or "mcp") and origin_id, set by the scheduler, Run now, alert actions and
  the MCP tools. Migration 0031 backfills past runs from alert events,
  schedules' last runs, and MCP audit entries.
- The runs list has a Started by column: "schedule nightly", "alert rule
  nas down", "alice via MCP" or "alice", linked where there is a page.
  The raw scope tooltip is gone.
- Run detail: no run id in the subtitle or body, Started by in words, and
  the host summary lists failed and unreachable hosts first. The live
  output only follows new lines while the reader is at the bottom, and
  when the run finishes the page reloads once so the badge and summary
  are final.
- list_ansible_runs and get_ansible_run report origin and origin_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:26:17 -04:00
co-authored by Claude Opus 5.5
parent a96112a0e1
commit e780932673
14 changed files with 192 additions and 27 deletions
+38 -4
View File
@@ -7,7 +7,8 @@ import pytest
from quart import render_template, session
from steward.ansible.inventory_gen import run_title
from steward.ansible.routes import run_duration, run_view
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
@@ -53,7 +54,8 @@ async def test_the_runs_list_names_each_run_with_one_badge(app):
page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x")
main = page[page.index("<main"):]
assert '<a href="/ansible/runs/aaaa1111" title="steward:target:t1">update.yml on web-1</a>' in main
assert '<a 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
@@ -71,14 +73,22 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
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, triggered_label="system",
row=run_view(run, {"steward:target:t1": "target web-1"}, now))
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)
@@ -90,3 +100,27 @@ async def test_run_started_is_on_the_palette(app):
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}) == []
@@ -111,6 +111,7 @@ def test_firing_triggers_ansible_action(app, tmp_path):
run, event = asyncio.run(_go())
assert run is not None, "no AnsibleRun was created by the firing rule"
assert run.triggered_by is None # system-triggered
assert run.origin == "alert" and run.origin_id == event.rule_id # the runs list names the rule (#5212)
assert run.status == AnsibleRunStatus.success, run.output
assert "FIRED-cpu_pct" in (run.output or "") # injected context var reached the play
assert event.ansible_run_id == run.id # firing event linked to the run