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
+52
View File
@@ -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}
+17 -10
View File
@@ -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/<run_id>/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():
+6
View File
@@ -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,
+2
View File
@@ -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)