From e780932673cea8faa4235ab6b0776f4e138f3493 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 00:26:17 -0400 Subject: [PATCH] feat(ui): each run says who or what started it; run detail without ids (#5212) #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 --- plugins/docker/mcp_tools.py | 2 +- steward/ansible/origins.py | 52 +++++++++++++++++++ steward/ansible/routes.py | 27 ++++++---- steward/ansible/runner.py | 6 +++ steward/ansible/scheduler.py | 2 + steward/core/alerts.py | 3 +- steward/mcp/tools/automation.py | 5 +- .../versions/0031_ansible_run_origin.py | 41 +++++++++++++++ steward/models/ansible.py | 5 ++ steward/templates/ansible/_runs_page.html | 7 +-- steward/templates/ansible/index.html | 1 + steward/templates/ansible/run_detail.html | 25 ++++++--- tests/core/test_ansible_runs_page.py | 42 +++++++++++++-- .../integration/test_alert_ansible_action.py | 1 + 14 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 steward/ansible/origins.py create mode 100644 steward/migrations/versions/0031_ansible_run_origin.py diff --git a/plugins/docker/mcp_tools.py b/plugins/docker/mcp_tools.py index df2533b..5ac7858 100644 --- a/plugins/docker/mcp_tools.py +++ b/plugins/docker/mcp_tools.py @@ -626,7 +626,7 @@ async def prune_docker(host: str, what: str, check: bool = True) -> dict: mcp_app(), source_name=BUILTIN_SOURCE_NAME, playbook_path=PRUNE_BUILTIN_PLAYBOOK, inventory_scope=f"steward:target:{target.id}", params={"extra_vars_map": docker_svc.prune_extra_vars(what)}, - triggered_by=caller.user_id) + triggered_by=caller.user_id, origin="mcp") if err: raise ValueError(f"the prune could not start: {err}.") await audit_mcp("docker.prune.started", "host", h.name, diff --git a/steward/ansible/origins.py b/steward/ansible/origins.py new file mode 100644 index 0000000..02c701c --- /dev/null +++ b/steward/ansible/origins.py @@ -0,0 +1,52 @@ +"""Who or what started each run, as the runs list and run detail say it. + +A run records the user who started it (triggered_by) and where it came from +(origin / origin_id, migration 0031): a schedule, an alert rule, or an agent +over MCP. This turns those into "schedule nightly", "alert rule nas down", +"alice via MCP" or "alice", in three queries for a whole page of runs. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.models.alerts import AlertRule +from steward.models.ansible_schedule import AnsibleSchedule +from steward.models.users import User + + +def describe_origin(run, users: dict, schedules: dict, rules: dict) -> dict: + """{"text", "url", "kind"} for one run, from id → name maps. A schedule or + rule deleted since is named as such; a run with neither a user nor an + origin predates origins and was started by Steward itself.""" + who = users.get(run.triggered_by, "a deleted user") if run.triggered_by else None + if run.origin == "schedule": + name = schedules.get(run.origin_id) + text = f"schedule {name}" if name else "a deleted schedule" + if who: + text += f" (run now by {who})" + return {"text": text, "url": "/ansible/schedules" if name else None, "kind": "schedule"} + if run.origin == "alert": + name = rules.get(run.origin_id) + return {"text": f"alert rule {name}" if name else "a deleted alert rule", + "url": f"/alerts/history?rule={run.origin_id}" if name else None, "kind": "alert"} + if run.origin == "mcp": + return {"text": f"{who or 'an agent'} via MCP", "url": None, "kind": "mcp"} + if who: + return {"text": who, "url": None, "kind": "user"} + return {"text": "Steward", "url": None, "kind": "system"} + + +async def run_origins(db, runs) -> dict[str, dict]: + """{run id: describe_origin(run)} for a page of runs.""" + runs = list(runs) + user_ids = {r.triggered_by for r in runs if r.triggered_by} + sched_ids = {r.origin_id for r in runs if r.origin == "schedule" and r.origin_id} + rule_ids = {r.origin_id for r in runs if r.origin == "alert" and r.origin_id} + users = dict((await db.execute( + select(User.id, User.username).where(User.id.in_(user_ids)))).all()) if user_ids else {} + schedules = dict((await db.execute( + select(AnsibleSchedule.id, AnsibleSchedule.name).where(AnsibleSchedule.id.in_(sched_ids)))).all()) \ + if sched_ids else {} + rules = dict((await db.execute( + select(AlertRule.id, AlertRule.name).where(AlertRule.id.in_(rule_ids)))).all()) if rule_ids else {} + return {r.id: describe_origin(r, users, schedules, rules) for r in runs} diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 583f7ee..c9badea 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -10,12 +10,13 @@ from sqlalchemy import select from steward.ansible import executor, guards, sources as src_module from steward.ansible.inventory_gen import run_title, scope_labels +from steward.ansible.origins import run_origins from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.core.paging import keyset, next_page from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.ansible_schedule import AnsibleSchedule -from steward.models.users import User, UserRole +from steward.models.users import UserRole INTERVAL_PRESETS = [ (300, "Every 5 minutes"), @@ -45,8 +46,10 @@ async def index(): runs, after = next_page((await db.execute(stmt)).scalars().all(), lambda r: (r.started_at, r.id)) labels = await scope_labels(db, [r.inventory_scope for r in runs]) + origins = await run_origins(db, runs) now = datetime.now(timezone.utc) - ctx = {"runs": [run_view(r, labels, now) for r in runs], "after": after} + ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs], + "after": after} if before: return await render_template("ansible/_runs_page.html", **ctx) return await render_template("ansible/index.html", **ctx) @@ -359,18 +362,22 @@ async def run_detail(run_id: str): run = result.scalar_one_or_none() if run is None: return "Not found", 404 - # NULL triggered_by = an automated (system) run; otherwise resolve the user. - triggered_label = "system" - if run.triggered_by: - res = await db.execute( - select(User.username).where(User.id == run.triggered_by)) - triggered_label = res.scalar_one_or_none() or "(deleted user)" + origin = (await run_origins(db, [run]))[run.id] labels = await scope_labels(db, [run.inventory_scope]) return await render_template( - "ansible/run_detail.html", run=run, triggered_label=triggered_label, + "ansible/run_detail.html", run=run, origin=origin, hosts=hosts_worst_first(run.results), row=run_view(run, labels, datetime.now(timezone.utc))) +def hosts_worst_first(results: dict | None) -> list[tuple[str, dict]]: + """A run's per-host recap, failed or unreachable hosts first, then those + that changed something, then the rest, each by name.""" + hosts = (results or {}).get("hosts") or {} + return sorted(hosts.items(), key=lambda kv: ( + -((kv[1].get("failed") or 0) + (kv[1].get("unreachable") or 0) > 0), + -((kv[1].get("changed") or 0) > 0), kv[0])) + + @ansible_bp.post("/runs//cancel") @require_role(UserRole.operator) async def cancel_run(run_id: str): @@ -614,7 +621,7 @@ async def run_schedule_now(schedule_id: str): current_app._get_current_object(), # type: ignore[attr-defined] source_name=source_name, playbook_path=playbook_path, inventory_scope=inventory_scope, params=params, - triggered_by=session["user_id"], + triggered_by=session["user_id"], origin="schedule", origin_id=schedule_id, ) async with current_app.db_sessionmaker() as db: async with db.begin(): diff --git a/steward/ansible/runner.py b/steward/ansible/runner.py index 8f7ca6a..235b053 100644 --- a/steward/ansible/runner.py +++ b/steward/ansible/runner.py @@ -38,6 +38,8 @@ async def trigger_run( inventory_scope: str = "steward:all", params: dict | None = None, triggered_by: str | None = None, + origin: str | None = None, + origin_id: str | None = None, inventory_content: str | None = None, connection: dict | None = None, secret_vars: dict | None = None, @@ -46,6 +48,8 @@ async def trigger_run( """Resolve inventory for the scope, persist an AnsibleRun, and launch it. triggered_by=None marks a system/automated run (alerts, schedules). + origin / origin_id say where it came from (AnsibleRun.origin), so the runs + list can say "schedule nightly" or "alert rule nas down". If inventory_content is provided, it is used verbatim and scope resolution is skipped (the caller built a bespoke inventory — e.g. host_agent deploy injecting per-host tokens); inventory_scope is still recorded for display. @@ -88,6 +92,8 @@ async def trigger_run( inventory_scope=inventory_scope, source_name=source_name, triggered_by=triggered_by, + origin=origin, + origin_id=origin_id, status=AnsibleRunStatus.running, started_at=datetime.now(timezone.utc), params=params or None, diff --git a/steward/ansible/scheduler.py b/steward/ansible/scheduler.py index 17a67c1..c458847 100644 --- a/steward/ansible/scheduler.py +++ b/steward/ansible/scheduler.py @@ -64,6 +64,8 @@ async def run_due_schedules(app) -> int: inventory_scope=s.inventory_scope, params=s.params, triggered_by=None, + origin="schedule", + origin_id=s.id, ) except Exception: logger.exception("Schedule %s (%s) failed to launch", s.id, s.name) diff --git a/steward/core/alerts.py b/steward/core/alerts.py index db0c452..87801a1 100644 --- a/steward/core/alerts.py +++ b/steward/core/alerts.py @@ -590,7 +590,8 @@ async def _start_ansible_action(app, rule, value, event_id, trigger_run) -> None try: run, _source, err = await trigger_run( app, source_name=source_name, playbook_path=playbook, - inventory_scope=scope, params=params, triggered_by=None) + inventory_scope=scope, params=params, triggered_by=None, + origin="alert", origin_id=rule.id) except Exception as exc: logger.exception("Ansible action for rule %r failed to start", rule.name) run, err = None, str(exc) diff --git a/steward/mcp/tools/automation.py b/steward/mcp/tools/automation.py index 6883af2..797b51a 100644 --- a/steward/mcp/tools/automation.py +++ b/steward/mcp/tools/automation.py @@ -289,6 +289,9 @@ def run_row(r: AnsibleRun, users: dict, labels: dict) -> dict: "status": r.status.value, "check": bool((r.params or {}).get("check")), "triggered_by": users.get(r.triggered_by, "(deleted user)") if r.triggered_by else "system", + # "schedule" / "alert" (origin_id = that schedule or rule), "mcp", or null for the web pages. + "origin": r.origin, + "origin_id": r.origin_id, "started_at": iso(r.started_at), "age_s": age_s(r.started_at), "finished_at": iso(end), @@ -459,7 +462,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all", run, _src, err = await trigger_run( mcp_app(), source_name=s["name"], playbook_path=playbook.strip(), inventory_scope=stored_scope, params=params or None, - secret_vars=secret or None, triggered_by=caller.user_id) + secret_vars=secret or None, triggered_by=caller.user_id, origin="mcp") if err: raise ValueError(f"the run could not start: {err}.") await audit_mcp("ansible.run.started", "ansible_run", run.id, { diff --git a/steward/migrations/versions/0031_ansible_run_origin.py b/steward/migrations/versions/0031_ansible_run_origin.py new file mode 100644 index 0000000..67615fc --- /dev/null +++ b/steward/migrations/versions/0031_ansible_run_origin.py @@ -0,0 +1,41 @@ +"""Where a run came from: a schedule, an alert rule, or an agent over MCP (#5212) + +Revision ID: 0031_ansible_run_origin +Revises: 0030_plugin_metric_latest +Create Date: 2026-10-07 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0031_ansible_run_origin" +down_revision: Union[str, None] = "0030_plugin_metric_latest" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("ansible_runs", sa.Column("origin", sa.String(16), nullable=True)) + op.add_column("ansible_runs", sa.Column("origin_id", sa.String(36), nullable=True)) + # Recover what the record already says about past runs. These tables are + # small (runs, alert events that started one, audit entries), unlike the + # metrics seed 0030 left to the background. + op.execute(""" + UPDATE ansible_runs r SET origin = 'alert', origin_id = e.rule_id + FROM alert_events e WHERE e.ansible_run_id = r.id + """) + op.execute(""" + UPDATE ansible_runs r SET origin = 'schedule', origin_id = s.id + FROM ansible_schedules s WHERE s.last_run_id = r.id AND r.origin IS NULL + """) + op.execute(""" + UPDATE ansible_runs r SET origin = 'mcp' + FROM audit_events a + WHERE a.action = 'ansible.run.started' AND a.entity_id = r.id + AND a.username LIKE '% via MCP' AND r.origin IS NULL + """) + + +def downgrade() -> None: + op.drop_column("ansible_runs", "origin_id") + op.drop_column("ansible_runs", "origin") diff --git a/steward/models/ansible.py b/steward/models/ansible.py index f0ace14..8580772 100644 --- a/steward/models/ansible.py +++ b/steward/models/ansible.py @@ -31,6 +31,11 @@ class AnsibleRun(Base): triggered_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=True ) + # Where the run came from, beyond who started it: "schedule" or "alert" + # (origin_id is that schedule's or rule's id), or "mcp" for an agent on a + # user's token. NULL is a person on the web pages. + origin: Mapped[str | None] = mapped_column(String(16), nullable=True) + origin_id: Mapped[str | None] = mapped_column(String(36), nullable=True) status: Mapped[AnsibleRunStatus] = mapped_column( Enum(AnsibleRunStatus), nullable=False, default=AnsibleRunStatus.running ) diff --git a/steward/templates/ansible/_runs_page.html b/steward/templates/ansible/_runs_page.html index 0fe55ff..c97ce1e 100644 --- a/steward/templates/ansible/_runs_page.html +++ b/steward/templates/ansible/_runs_page.html @@ -5,17 +5,18 @@ {% set run = row.run %} - {{ row.title }} + {{ row.title }} {% if row.check %}dry run{% endif %}
{{ run.source_name }}
{{ status_badge(run.status) }} + {% if row.origin %}{% if row.origin.url %}{{ row.origin.text }}{% else %}{{ row.origin.text }}{% endif %}{% endif %} {{ run.started_at|ago }} {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} {% endfor %} {% if after %} -{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=4) }} +{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=5) }} {% else %} -{{ scroll_end(cols=4) }} +{{ scroll_end(cols=5) }} {% endif %} diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index 5ad9d35..416b150 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -16,6 +16,7 @@ Run Status + Started by Started Duration diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index af73437..fed683c 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -3,7 +3,7 @@ {% block title %}{{ row.title }} — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), (row.title, "")]) }}{% endblock %} {% block content %} -{% call page_header(row.title, "Run " ~ run.id[:8] ~ " from " ~ run.source_name) %} +{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %} {% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
{{ csrf_field() }} @@ -20,17 +20,16 @@
Playbook
{{ run.playbook_path }}
Scope
{{ row.scope_label }}
Source
{{ run.source_name }}
-
Triggered by
{{ triggered_label }}
+
Started by
{% if origin.url %}{{ origin.text }}{% else %}{{ origin.text }}{% endif %}
{% if run.params %} {% if run.params.limit %}
Limit
{{ run.params.limit }}
{% endif %} {% if run.params.tags %}
Tags
{{ run.params.tags }}
{% endif %} {% if run.params.extra_vars %}
Extra vars
{{ run.params.extra_vars | join(", ") }}
{% endif %} {% endif %} -
ID
{{ run.id }}
-{% if run.results and run.results.hosts %} +{% if hosts %}
Host summary
@@ -41,7 +40,7 @@ - {% for host, c in run.results.hosts.items() %} + {% for host, c in hosts %} @@ -75,13 +74,25 @@ var es = new EventSource('/ansible/runs/{{ run.id }}/stream'); var pre = document.getElementById('live-output'); var status = document.getElementById('run-done-status'); + // Follow new lines only while the reader is at the bottom; scrolled + // up to read something, they keep their place. es.addEventListener('output', function(e) { + var atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24; pre.textContent += e.data + '\n'; - pre.scrollTop = pre.scrollHeight; + if (atBottom) pre.scrollTop = pre.scrollHeight; }); + // Finished: load the page again, so the status, duration and host + // summary are the run's final ones rather than "Running". The + // executor saves the final status before it says done; should that + // save have failed, ?finished stops a second reload going round. es.addEventListener('done', function(e) { es.close(); - status.textContent = 'Finished: ' + e.data; + if (window.location.search.indexOf('finished=1') === -1) { + status.textContent = 'Finished. Loading the summary…'; + window.location.replace(window.location.pathname + '?finished=1'); + } else { + status.textContent = 'Finished (' + e.data + '), but the summary could not be saved.'; + } }); })(); diff --git a/tests/core/test_ansible_runs_page.py b/tests/core/test_ansible_runs_page.py index 8ef2762..03d3be1 100644 --- a/tests/core/test_ansible_runs_page.py +++ b/tests/core/test_ansible_runs_page.py @@ -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("update.yml on web-1' in main + assert 'update.yml on web-1' in main + assert "steward:target:t1" not in main # no raw scope, not even as a tooltip assert ">update.yml on all inventory targets" in main assert 'Running' in main assert 'Failed' 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 "update.yml on web-1 — Steward" in page assert 'href="/ansible/"' in page # breadcrumb back to the runs assert 'Interrupted' in page assert "
10m
" 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 '
schedule nightly
' in page + assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "") + assert page.index(">zulu<") < page.index(">alpha<") main = page[page.index("Running' 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}) == [] diff --git a/tests/integration/test_alert_ansible_action.py b/tests/integration/test_alert_ansible_action.py index 0658414..756ccd9 100644 --- a/tests/integration/test_alert_ansible_action.py +++ b/tests/integration/test_alert_ansible_action.py @@ -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
skipped
{{ host }} {{ c.ok }}