Files
FabledSteward/steward/ansible/recap.py
T
bvandeusenandClaude Opus 5.5 a02f224f22
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m46s
CI / publish (push) Successful in 1m9s
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>
2026-10-07 23:45:04 -04:00

64 lines
2.7 KiB
Python

"""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