feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m46s
CI / publish (push) Successful in 1m9s

- 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:
2026-10-07 23:45:04 -04:00
co-authored by Claude Opus 5.5
parent 7499c38037
commit a02f224f22
18 changed files with 519 additions and 111 deletions
+104 -13
View File
@@ -6,11 +6,12 @@ from quart import (
Blueprint, Response, current_app, redirect, render_template,
request, session, url_for,
)
from sqlalchemy import select
from sqlalchemy import func, 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.ansible.recap import host_recap, run_recap
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.core.paging import keyset, next_page
@@ -35,24 +36,112 @@ def _get_sources() -> list[dict]:
return src_module.get_sources(ansible_cfg)
# The runs list's filters (#5329): what each status choice and origin choice
# covers. A run started on the web pages has no origin and a user.
RUN_STATUSES = {
"failed": ("Failed", (AnsibleRunStatus.failed, AnsibleRunStatus.interrupted)),
"success": ("Succeeded", (AnsibleRunStatus.success,)),
"running": ("Running", (AnsibleRunStatus.running, AnsibleRunStatus.queued)),
"cancelled": ("Cancelled", (AnsibleRunStatus.cancelled,)),
}
RUN_ORIGINS = {"schedule": "A schedule", "alert": "An alert rule", "mcp": "An AI agent", "manual": "A person"}
VERDICT_DAYS = 30
def run_filters(args) -> dict:
"""The runs list's filters from the query string, unknown values dropped."""
status = args.get("status", "")
origin = args.get("origin", "")
return {"q": (args.get("q") or "").strip()[:200],
"status": status if status in RUN_STATUSES else "",
"origin": origin if origin in RUN_ORIGINS else ""}
def filter_runs(stmt, f: dict):
"""`stmt` narrowed to the runs the filters choose."""
if f["q"]:
stmt = stmt.where(func.lower(AnsibleRun.playbook_path).contains(f["q"].lower(), autoescape=True))
if f["status"]:
stmt = stmt.where(AnsibleRun.status.in_(RUN_STATUSES[f["status"]][1]))
if f["origin"] == "manual":
stmt = stmt.where(AnsibleRun.origin.is_(None), AnsibleRun.triggered_by.is_not(None))
elif f["origin"]:
stmt = stmt.where(AnsibleRun.origin == f["origin"])
return stmt
def next_scheduled(schedules, now: datetime) -> dict | None:
"""The enabled schedule due soonest: {"name", "at"}, `at` None when it is
due now (it has never run, or its turn has passed)."""
best = None
for s in schedules:
if not s.enabled:
continue
at = s.last_run_at + timedelta(seconds=s.interval_seconds) if s.last_run_at else None
if at is not None and at <= now:
at = None
key = at or now
if best is None or key < best[0]:
best = (key, {"name": s.name, "at": at})
return best[1] if best else None
async def runs_verdict(db, now: datetime) -> dict:
"""What the Runs page opens on: runs in the last VERDICT_DAYS days, how
many failed and the newest of those, how many are going now, and the
schedule due next."""
since = now - timedelta(days=VERDICT_DAYS)
bad = RUN_STATUSES["failed"][1]
going = RUN_STATUSES["running"][1]
total, failed, running = (await db.execute(select(
func.count(),
func.count().filter(AnsibleRun.status.in_(bad)),
func.count().filter(AnsibleRun.status.in_(going)),
).select_from(AnsibleRun).where(AnsibleRun.started_at >= since))).one()
latest_failed = None
if failed:
run = (await db.execute(select(AnsibleRun).where(
AnsibleRun.started_at >= since, AnsibleRun.status.in_(bad))
.order_by(AnsibleRun.started_at.desc()).limit(1))).scalar_one()
labels = await scope_labels(db, [run.inventory_scope])
latest_failed = run_view(run, labels, now)
schedules = (await db.execute(select(AnsibleSchedule))).scalars().all()
return {"days": VERDICT_DAYS, "total": total, "failed": failed, "running": running,
"latest_failed": latest_failed, "next": next_scheduled(schedules, now)}
@ansible_bp.get("/")
@require_role(UserRole.viewer)
async def index():
"""Every run, newest first, a page at a time as the reader scrolls;
?before=<cursor> is the next page's rows alone (core.paging)."""
"""Every run, newest first, a page at a time as the reader scrolls, under
a verdict line and a filter bar (#5329). ?before=<cursor> is the next
page's rows alone (core.paging); an HTMX request from the filter bar is
the table alone."""
from urllib.parse import urlencode
f = run_filters(request.args)
before = request.args.get("before")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
stmt = keyset(select(AnsibleRun), AnsibleRun.started_at, AnsibleRun.id, before)
stmt = keyset(filter_runs(select(AnsibleRun), f), AnsibleRun.started_at, AnsibleRun.id, before)
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)
verdict = None if before or request.headers.get("HX-Request") else await runs_verdict(db, now)
kept = {k: v for k, v in f.items() if v}
ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
"after": after}
"more_url": "/ansible/?" + urlencode({**kept, "before": after}) if after else None,
"f": f, "filtered": bool(kept), "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
"verdict": verdict}
if before:
return await render_template("ansible/_runs_page.html", **ctx)
return await render_template("ansible/index.html", **ctx)
if request.headers.get("HX-Request"):
resp = await current_app.make_response(await render_template("ansible/_runs_table.html", **ctx))
else:
resp = await current_app.make_response(await render_template("ansible/index.html", **ctx))
resp.vary.add("HX-Request")
return resp
def run_duration(run: AnsibleRun, now: datetime) -> float | None:
@@ -63,11 +152,13 @@ def run_duration(run: AnsibleRun, now: datetime) -> float | None:
def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict:
"""A run as the pages name it: its title, scope label and duration."""
"""A run as the pages name it: its title, scope label, duration and what
it did across its hosts in words (#5329)."""
label = labels.get(run.inventory_scope, run.inventory_scope)
check = bool((run.params or {}).get("check"))
return {"run": run, "title": run_title(run.playbook_path, label), "scope_label": label,
"duration_s": run_duration(run, now),
"check": bool((run.params or {}).get("check"))}
"duration_s": run_duration(run, now), "check": check,
"recap": run_recap(run.results, check)}
@ansible_bp.get("/browse")
@@ -364,9 +455,9 @@ async def run_detail(run_id: str):
return "Not found", 404
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, origin=origin, hosts=hosts_worst_first(run.results),
row=run_view(run, labels, datetime.now(timezone.utc)))
row = run_view(run, labels, datetime.now(timezone.utc))
hosts = [(name, host_recap(c, row["check"])) for name, c in hosts_worst_first(run.results)]
return await render_template("ansible/run_detail.html", run=run, origin=origin, hosts=hosts, row=row)
def hosts_worst_first(results: dict | None) -> list[tuple[str, dict]]: