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
+63
View File
@@ -0,0 +1,63 @@
"""A run's play recap in words (#5329, #5330): what the runs list says under a
run's status and what the run page says beside each host.
A run's `results` is {hosts: {name: {ok, changed, unreachable, failed,
skipped}}, failures: [...]}, parsed from the play recap. Model-free, so it is
unit-tested without a database.
"""
from __future__ import annotations
NAMES_SHOWN = 2
def _n(counts: dict | None, key: str) -> int:
return int((counts or {}).get(key) or 0)
def _names(names: list[str]) -> str:
""""a", "a and b", "a, b and 3 more"."""
if len(names) <= NAMES_SHOWN:
return " and ".join(names)
return f"{', '.join(names[:NAMES_SHOWN])} and {len(names) - NAMES_SHOWN} more"
def run_recap(results: dict | None, check: bool = False) -> list[tuple[str, str | None]]:
"""A run's outcome across its hosts as (text, tone) parts, the worst
first: "failed on web-1" (crit), "db-2 unreachable" (crit), "3 hosts",
"2 changed" ("would change" for a dry run, which changed nothing).
Empty when the run left no recap (it is still going, or died first)."""
hosts = (results or {}).get("hosts") or {}
if not hosts:
return []
failed = sorted(h for h, c in hosts.items() if _n(c, "failed"))
unreachable = sorted(h for h, c in hosts.items() if _n(c, "unreachable") and h not in failed)
changed = sum(1 for c in hosts.values() if _n(c, "changed"))
parts: list[tuple[str, str | None]] = []
if failed:
parts.append((f"failed on {_names(failed)}", "crit"))
if unreachable:
parts.append((f"{_names(unreachable)} unreachable", "crit"))
parts.append((f"{len(hosts)} host{'s' if len(hosts) != 1 else ''}", None))
if changed:
parts.append((f"{changed} would change" if check else f"{changed} changed", None))
elif not failed and not unreachable:
parts.append(("nothing to change" if check else "nothing changed", "muted"))
return parts
def host_recap(counts: dict | None, check: bool = False) -> list[tuple[str, str | None]]:
"""One host's task counts as (text, tone) parts, zeros left out and the
worst first: "unreachable", "1 failed", "3 ok", "1 changed", "2 skipped"."""
parts: list[tuple[str, str | None]] = []
if _n(counts, "unreachable"):
parts.append(("unreachable", "crit"))
if _n(counts, "failed"):
parts.append((f"{_n(counts, 'failed')} failed", "crit"))
if _n(counts, "ok"):
parts.append((f"{_n(counts, 'ok')} ok", None))
if _n(counts, "changed"):
word = "would change" if check else "changed"
parts.append((f"{_n(counts, 'changed')} {word}", None))
if _n(counts, "skipped"):
parts.append((f"{_n(counts, 'skipped')} skipped", "muted"))
return parts
+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]]: