feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330)
- 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:
@@ -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
@@ -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]]:
|
||||
|
||||
Reference in New Issue
Block a user