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]]:
+12
View File
@@ -352,6 +352,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; }
.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; }
/* A section's pages as tabs (section_tabs in _macros.html, #5328). It
scrolls inside itself on a phone rather than widening the page. */
.section-tabs { display: flex; gap: 0.25rem; margin: 0 0 1.25rem; border-bottom: 1px solid var(--border); overflow-x: auto; }
.section-tabs a { padding: 0.5rem 0.9rem; margin-bottom: -1px; border-bottom: 2px solid transparent;
color: var(--text-muted); font-size: 0.9rem; white-space: nowrap; }
.section-tabs a:hover { color: var(--text); }
.section-tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
/* A label/value list: <dl class="kv"><dt>…</dt><dd>…</dd></dl>. */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; }
.kv dt { color: var(--text-muted); }
@@ -363,6 +370,11 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
font-family: var(--fs-font-mono); font-size: 0.78rem; color: var(--text-muted);
max-height: 400px; overflow-y: auto; white-space: pre-wrap;
}
/* A run's log on its page (ansible/run_detail.html), and what failed. */
.run-log { margin: 0; padding: 1rem; background: var(--bg); font-family: var(--fs-font-mono); font-size: 0.78rem;
color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; }
.run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; }
.run-log-status { margin: 0; padding: 0.5rem 1rem; }
/* Monospaced data: ids, limits, tags, paths. */
.mono { font-family: var(--fs-font-mono); font-size: 0.85em; }
/* The host page's health header (_host_vitals.html). */
+11
View File
@@ -73,6 +73,17 @@
</div>
{%- endmacro %}
{# A section's own pages as tabs under the crumbs (#5328): items are
(key, label, href); the one whose key is `active` is the current page.
Links, not a tablist: each tab is its own page. #}
{% macro section_tabs(items, active, label) -%}
<nav class="section-tabs" aria-label="{{ label }}">
{%- for key, text, href in items %}
<a href="{{ href }}"{% if key == active %} aria-current="page"{% endif %}>{{ text }}</a>
{%- endfor %}
</nav>
{%- endmacro %}
{# A button that shows only an icon. The label is required: it is the button's
accessible name and its tooltip, and rendering one without it raises. #}
{% macro icon_button(icon, label, href=none, cls="btn-ghost") -%}
+16 -11
View File
@@ -1,23 +1,28 @@
{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the
next page as the reader nears it (core.paging, pref 172). The whole row
opens the run (.row-link); its title is the one real link. #}
opens the run (.row-link); its title is the one real link. Under the status,
what the run did across its hosts (ansible.recap.run_recap). #}
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for row in runs %}
{% set run = row.run %}
<tr class="row-link">
<td>
<a class="row-target" href="/ansible/runs/{{ run.id }}">{{ row.title }}</a>
{% if row.check %}<span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
<div class="field-hint">{{ run.source_name }}</div>
<td class="cell-name">
<a class="row-target" href="/ansible/runs/{{ run.id }}" title="{{ row.title }}">{{ row.title }}</a>
<div class="cell-sub">{{ run.source_name }}{% if row.check %} · <span class="tone-info" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}</div>
</td>
<td>{{ status_badge(run.status) }}</td>
<td style="font-size:0.85rem;">{% if row.origin %}{% if row.origin.url %}<a href="{{ row.origin.url }}">{{ row.origin.text }}</a>{% else %}{{ row.origin.text }}{% endif %}{% endif %}</td>
<td title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
<td>{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
<td>
{{ status_badge(run.status) }}
{%- if row.recap %}
<div class="cell-sub">{% for text, tone in row.recap %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}</div>
{%- endif %}
</td>
<td>{% if row.origin %}{{ row.origin.text }}{% endif %}</td>
<td class="nowrap" title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
<td class="num nowrap">{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=5) }}
{% if more_url %}
{{ scroll_more(more_url, cols=5) }}
{% else %}
{{ scroll_end(cols=5) }}
{% endif %}
@@ -0,0 +1,26 @@
{# ansible/_runs_table.html — the runs table under the filter bar; the bar's
HTMX requests swap this alone. #}
{% from "_macros.html" import empty_state %}
{% if not runs %}
<div class="card">
{%- if filtered %}{{ empty_state("No runs match these filters.", "Show every run", "/ansible/") }}
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/browse") }}{% endif -%}
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Run</th>
<th>Result</th>
<th>Started by</th>
<th>Started</th>
<th class="num">Took</th>
</tr>
</thead>
<tbody>
{% include "ansible/_runs_page.html" %}
</tbody>
</table>
</div>
{% endif %}
+8
View File
@@ -0,0 +1,8 @@
{# ansible/_tabs.html — the Ansible section's pages as one strip of tabs (#5328),
on every Ansible page; `active` names the page or the one it sits under. #}
{% from "_macros.html" import section_tabs %}
{% macro ansible_tabs(active) -%}
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/browse"),
("schedules", "Schedules", "/ansible/schedules"),
("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }}
{%- endmacro %}
+2 -2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Browse Playbooks — Steward{% endblock %}
{% block breadcrumb %}
@@ -9,14 +10,13 @@
{%- endif -%}
{% endblock %}
{% block content %}
{{ ansible_tabs("playbooks") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
<div style="display:flex;gap:0.5rem;">
{% if session.user_role == 'admin' %}
<a href="/ansible/playbooks/new" class="btn btn-sm">New playbook</a>
{% endif %}
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Inventory</a>
<a href="/ansible/schedules" class="btn btn-ghost btn-sm">Schedules</a>
</div>
</div>
+35 -25
View File
@@ -1,30 +1,40 @@
{# ansible/index.html — the Runs tab (#5329): a verdict line (how the last
30 days went, failures first, what runs next), a filter bar, then every run
newest first as the reader scrolls (ansible/_runs_table.html). #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% from "_macros.html" import page_header %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% block title %}Ansible Runs — Steward{% endblock %}
{% block content %}
{% call page_header("Ansible Runs", "Each run by what it ran where, newest first.") %}
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
<a href="/ansible/schedules" class="btn btn-ghost">Schedules</a>
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
{{ ansible_tabs("runs") }}
{% call page_header("Ansible Runs") %}
<a href="/ansible/browse" class="btn">Run a playbook</a>
{% endcall %}
{% if not runs %}
<div class="card">{{ empty_state("No runs yet.", "Browse playbooks", "/ansible/browse") }}</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Run</th>
<th>Status</th>
<th>Started by</th>
<th>Started</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{% include "ansible/_runs_page.html" %}
</tbody>
</table>
</div>
{% endif %}
{% set v = verdict %}
<p class="page-verdict">
{%- if not v.total %}<span class="tone-muted">No runs in the last {{ v.days }} days</span>
{%- elif v.failed %}<span class="tone-crit">{{ v.failed }} of {{ v.total }} run{{ 's' if v.total != 1 }} failed</span> in the last {{ v.days }} days
{%- if v.latest_failed %} — latest <a href="/ansible/runs/{{ v.latest_failed.run.id }}">{{ v.latest_failed.title }}</a> {{ v.latest_failed.run.started_at|ago }}{% endif %}
{%- else %}<span class="tone-ok">{{ v.total }} run{{ 's' if v.total != 1 }} in the last {{ v.days }} days, {{ 'all' if v.total > 1 else 'it' }} succeeded</span>{% endif %}
{%- if v.running %} · <span class="tone-info">{{ v.running }} running now</span>{% endif %}
{%- if v.next %} · next: <a href="/ansible/schedules">{{ v.next.name }}</a> {% if v.next.at %}{{ v.next.at|ago }}{% else %}due now{% endif %}{% endif -%}
</p>
<form class="filter-bar" method="get" action="/ansible/" role="search"
hx-target="#runs-table" hx-push-url="true" hx-include="closest form">
<input type="search" name="q" value="{{ f.q }}" class="input" placeholder="Search playbooks"
aria-label="Search runs by playbook" autocomplete="off"
hx-get="/ansible/" hx-trigger="input changed delay:250ms, search">
<select name="status" aria-label="Status" hx-get="/ansible/" hx-trigger="change">
<option value="">Any status</option>
{% for key, (label, _) in statuses.items() %}<option value="{{ key }}"{% if f.status == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<select name="origin" aria-label="Started by" hx-get="/ansible/" hx-trigger="change">
<option value="">Started by anyone</option>
{% for key, label in origins.items() %}<option value="{{ key }}"{% if f.origin == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
</form>
<div id="runs-table">{% include "ansible/_runs_table.html" %}</div>
{% endblock %}
@@ -1,8 +1,10 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Group: {{ group.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "/ansible/inventory/groups"), (group.name, "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("inventory") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
</div>
@@ -1,8 +1,10 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Inventory Groups — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("inventory") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
<div style="display:flex;gap:0.5rem;">
@@ -1,8 +1,10 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Target: {{ target.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "/ansible/inventory/targets"), (target.name, "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("inventory") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
</div>
@@ -1,8 +1,10 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}Inventory Targets — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("inventory") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
<div style="display:flex;gap:0.5rem;">
@@ -1,8 +1,10 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs %}
{% block title %}{{ "Edit" if editing else "New" }} Playbook — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
{% block content %}
{{ ansible_tabs("playbooks") }}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">{{ "Edit playbook" if editing else "New playbook" }}</h1>
</div>
+38 -41
View File
@@ -1,9 +1,17 @@
{# ansible/run_detail.html — one run (#5330): a verdict line (how it ended,
how long, when, who started it, what it did across its hosts), what it ran
with, each host's result in words, worst first, then its output. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% block title %}{{ row.title }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), (row.title, "")]) }}{% endblock %}
{% macro words(parts) -%}
{% for text, tone in parts %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}
{%- endmacro %}
{% block content %}
{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %}
{{ ansible_tabs("runs") }}
{% call page_header(row.title) %}
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run? Ansible stops where it is; tasks already applied stay applied." data-confirm-dismiss="Keep running">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
@@ -12,15 +20,19 @@
<a href="/ansible/runs/{{ run.id }}/log" class="btn btn-sm btn-ghost">Download log</a>
{% endcall %}
<p class="page-verdict">
{{- status_badge(run.status) }}
{%- if row.check %} <span class="tone-info" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
{%- if row.duration_s is not none %} {% if run.finished_at %}in{% else %}for{% endif %} {{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}{% endif %}
· started {{ run.started_at|ago }}
· by {% if origin.url %}<a href="{{ origin.url }}">{{ origin.text }}</a>{% else %}{{ origin.text }}{% endif %}
{%- if row.recap %} · {{ words(row.recap) }}{% endif -%}
</p>
<div class="card">
<dl class="kv">
<dt>Status</dt><dd>{{ status_badge(run.status) }}{% if row.check %} <span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}</dd>
<dt>Started</dt><dd title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</dd>
<dt>Duration</dt><dd>{{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}</dd>
<dt>Playbook</dt><dd>{{ run.playbook_path }}</dd>
<dt>Scope</dt><dd title="{{ run.inventory_scope }}">{{ row.scope_label }}</dd>
<dt>Source</dt><dd>{{ run.source_name }}</dd>
<dt>Started by</dt><dd>{% if origin.url %}<a href="{{ origin.url }}">{{ origin.text }}</a>{% else %}{{ origin.text }}{% endif %}</dd>
<dt>Playbook</dt><dd><a href="/ansible/browse/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
<dt>On</dt><dd>{{ row.scope_label }}</dd>
{% if run.params %}
{% if run.params.limit %}<dt>Limit</dt><dd class="mono">{{ run.params.limit }}</dd>{% endif %}
{% if run.params.tags %}<dt>Tags</dt><dd class="mono">{{ run.params.tags }}</dd>{% endif %}
@@ -30,45 +42,30 @@
</div>
{% if hosts %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Host summary</div>
<section class="card-flush" aria-labelledby="run-hosts">
<h2 id="run-hosts" class="table-caption">Hosts <span class="tone-muted">worst first</span></h2>
<table class="table">
<thead><tr>
<th>Host</th>
<th style="text-align:center;">ok</th><th style="text-align:center;">changed</th>
<th style="text-align:center;">unreachable</th><th style="text-align:center;">failed</th>
<th style="text-align:center;">skipped</th>
</tr></thead>
<thead><tr><th>Host</th><th>Result</th></tr></thead>
<tbody>
{% for host, c in hosts %}
<tr>
<td style="font-family:ui-monospace,monospace;">{{ host }}</td>
<td style="text-align:center;">{{ c.ok }}</td>
<td style="text-align:center;color:{% if c.changed %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ c.changed }}</td>
<td style="text-align:center;color:{% if c.unreachable %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.unreachable }}</td>
<td style="text-align:center;color:{% if c.failed %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.failed }}</td>
<td style="text-align:center;color:var(--text-dim);">{{ c.skipped }}</td>
</tr>
{% for host, parts in hosts %}
<tr><td class="mono">{{ host }}</td><td>{{ words(parts) if parts else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if run.results.failures %}
<div class="section-title" style="margin:1rem 0 0.4rem;">Failures</div>
<pre style="margin:0;padding:0.75rem;background:var(--bg);border-radius:4px;font-size:0.75rem;color:var(--red);max-height:240px;overflow:auto;white-space:pre-wrap;">{{ run.results.failures | join("\n") }}</pre>
{% endif %}
</div>
</section>
{% endif %}
{% if run.results and run.results.failures %}
<section class="card" aria-labelledby="run-failures">
<h2 id="run-failures" class="section-title">What failed</h2>
<pre class="run-log run-failures">{{ run.results.failures | join("\n") }}</pre>
</section>
{% endif %}
<div class="card" style="padding:0;">
<div style="padding:0.75rem 1rem;background:var(--bg-elevated);border-bottom:1px solid var(--border);">
<span style="color:var(--text-muted);font-size:0.85rem;">Output</span>
</div>
<section class="card-flush" aria-labelledby="run-output">
<h2 id="run-output" class="table-caption">Output</h2>
{% if run.status.value in ("running", "queued") %}
<pre id="live-output"
style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:var(--text-muted);">
Streaming…
</div>
<pre id="live-output" class="run-log">{{ run.output or "" }}</pre>
<p id="run-done-status" class="field-hint run-log-status">Streaming…</p>
<script>
(function() {
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
@@ -97,7 +94,7 @@
})();
</script>
{% else %}
<pre style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--text-muted);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
<pre class="run-log">{{ run.output or "(no output)" }}</pre>
{% endif %}
</div>
</section>
{% endblock %}
+2 -4
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs, status_badge %}
{% block title %}Ansible Schedules — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Schedules", "")]) }}{% endblock %}
@@ -9,13 +10,10 @@
{% endmacro %}
{% block content %}
{{ ansible_tabs("schedules") }}
{% set can_edit = session.user_role in ('operator', 'admin') %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Ansible Schedules</h1>
<div style="display:flex;gap:0.5rem;">
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
<a href="/ansible/browse" class="btn btn-ghost">Browse</a>
</div>
</div>
{# ── Existing schedules ───────────────────────────────────────────────────── #}
+107 -15
View File
@@ -8,7 +8,10 @@ from quart import render_template, session
from steward.ansible.inventory_gen import run_title
from steward.ansible.origins import describe_origin
from steward.ansible.routes import hosts_worst_first, run_duration, run_view
from steward.ansible.recap import host_recap, run_recap
from steward.ansible.routes import (
RUN_ORIGINS, RUN_STATUSES, hosts_worst_first, next_scheduled, run_duration, run_filters, run_view,
)
from steward.models.ansible import AnsibleRunStatus
from tests.test_style_tokens import hex_colours
@@ -24,14 +27,14 @@ def test_a_run_is_named_by_what_ran_where(label, title):
assert run_title("update.yml", label) == title
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False):
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False, results=None):
now = datetime.now(timezone.utc)
started = now - timedelta(minutes=started_min_ago)
return SimpleNamespace(
id=rid, playbook_path="update.yml", source_name="builtin", inventory_scope=scope,
status=status, started_at=started,
finished_at=started + timedelta(seconds=took_s) if took_s is not None else None,
params={"check": True} if check else None, results=None, output="")
params={"check": True} if check else None, results=results, output="")
def test_a_running_run_counts_its_duration_so_far():
@@ -41,56 +44,145 @@ def test_a_running_run_counts_its_duration_so_far():
assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95
def _ctx(runs, verdict=None, f=None, more_url=None):
return {"runs": runs, "more_url": more_url, "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
"f": f or {"q": "", "status": "", "origin": ""}, "filtered": bool(f),
"verdict": verdict or {"days": 30, "total": 0, "failed": 0, "running": 0,
"latest_failed": None, "next": None}}
async def test_the_runs_list_names_each_run_with_one_badge(app):
now = datetime.now(timezone.utc)
labels = {"steward:target:t1": "target web-1", "steward:all": "all inventory targets"}
two = {"hosts": {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 1}}}
runs = [run_view(r, labels, now) for r in (
_run("aaaa1111", AnsibleRunStatus.running, 2),
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True),
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40),
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True,
results={"hosts": {"db-1": {"ok": 1, "failed": 1}, "web-1": {"ok": 2}}}),
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40, results=two),
)]
more = "/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"
async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer"
page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x")
page = await render_template("ansible/index.html", **_ctx(runs, more_url=more))
main = page[page.index("<main"):]
assert '<tr class="row-link">' in main # the whole row opens the run
assert '<a class="row-target" href="/ansible/runs/aaaa1111">update.yml on web-1</a>' in main
assert ('<a class="row-target" href="/ansible/runs/aaaa1111" title="update.yml on web-1">'
'update.yml on web-1</a>') in main
assert "steward:target:t1" not in main # no raw scope, not even as a tooltip
assert ">update.yml on all inventory targets</a>" in main
assert '<span class="badge badge-info">Running</span>' in main
assert '<span class="badge badge-crit">Failed</span>' in main
assert '<span class="badge badge-ok">Succeeded</span>' in main
# Under each status, what the run did across its hosts (#5329).
assert '<span class="tone-crit">failed on db-1</span> · 2 hosts' in main
assert "2 hosts · 2 changed" in main
# The list continues itself as the reader scrolls (#5211): no cap of 50.
assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main
assert f'data-scroll-more hx-get="{more}"' in main
assert "2m 5s" in main and "40s" in main and " so far" in main
assert "dry run" in main and "ago" in main
assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone
assert not hex_colours(main), hex_colours(main)
async def test_ansible_pages_share_one_strip_of_tabs(app):
async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer"
page = await render_template("ansible/index.html", **_ctx([]))
nav = page[page.index('<nav class="section-tabs" aria-label="Ansible">'):]
nav = nav[:nav.index("</nav>")]
for href in ("/ansible/", "/ansible/browse", "/ansible/schedules", "/ansible/inventory/targets"):
assert f'href="{href}"' in nav, href
assert nav.count('aria-current="page"') == 1 and '<a href="/ansible/" aria-current="page">Runs</a>' in nav
assert "No runs yet." in page and "No runs in the last 30 days" in page
async def test_the_runs_page_opens_on_its_verdict(app):
now = datetime.now(timezone.utc)
failed = run_view(_run("ffff0000", AnsibleRunStatus.failed, 90, took_s=5), {"steward:target:t1": "target web-1"}, now)
calm = {"days": 30, "total": 33, "failed": 0, "running": 0, "latest_failed": None,
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2, minutes=5)}}
bad = {"days": 30, "total": 33, "failed": 2, "running": 1, "latest_failed": failed, "next": None}
async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer"
ok = await render_template("ansible/index.html", **_ctx([], calm))
worse = await render_template("ansible/index.html", **_ctx([], bad, f={"q": "x", "status": "", "origin": ""}))
assert '<span class="tone-ok">33 runs in the last 30 days, all succeeded</span>' in ok
assert 'next: <a href="/ansible/schedules">Nightly prune</a> <time' in ok and ">in 2h" in ok
assert '<span class="tone-crit">2 of 33 runs failed</span> in the last 30 days' in worse
assert 'latest <a href="/ansible/runs/ffff0000">update.yml on web-1</a>' in worse
assert '<span class="tone-info">1 running now</span>' in worse
assert "No runs match these filters." in worse and 'value="x"' in worse
def test_the_run_filters_drop_what_they_do_not_know():
assert run_filters({"q": " prune ", "status": "failed", "origin": "mcp"}) == \
{"q": "prune", "status": "failed", "origin": "mcp"}
assert run_filters({"status": "exploded", "origin": "aliens"}) == {"q": "", "status": "", "origin": ""}
def test_the_next_schedule_is_the_soonest_enabled_one():
now = datetime.now(timezone.utc)
def sched(name, ran_min_ago, every_s, enabled=True):
return SimpleNamespace(name=name, enabled=enabled, interval_seconds=every_s,
last_run_at=now - timedelta(minutes=ran_min_ago) if ran_min_ago is not None else None)
daily, hourly = sched("daily", 60, 86400), sched("hourly", 30, 3600)
assert next_scheduled([daily, hourly], now)["name"] == "hourly"
assert next_scheduled([daily, sched("paused", 59, 3600, enabled=False)], now)["name"] == "daily"
assert next_scheduled([daily, sched("new", None, 3600)], now) == {"name": "new", "at": None} # due now
assert next_scheduled([], now) is None
def test_a_runs_recap_reads_in_words_worst_first():
hosts = {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 0},
"db-1": {"ok": 1, "failed": 1}, "nas": {"unreachable": 1}}
assert run_recap({"hosts": hosts}) == [
("failed on db-1", "crit"), ("nas unreachable", "crit"), ("4 hosts", None), ("1 changed", None)]
assert run_recap({"hosts": {"a": {"ok": 2}}}) == [("1 host", None), ("nothing changed", "muted")]
assert run_recap({"hosts": {"a": {"changed": 1}}}, check=True) == [("1 host", None), ("1 would change", None)]
many = {h: {"failed": 1} for h in ("a", "b", "c", "d")}
assert run_recap({"hosts": many})[0] == ("failed on a, b and 2 more", "crit")
assert run_recap(None) == [] and run_recap({"hosts": {}}) == []
def test_a_hosts_recap_leaves_out_zeros():
assert host_recap({"ok": 3, "changed": 1, "unreachable": 0, "failed": 0, "skipped": 2}) == [
("3 ok", None), ("1 changed", None), ("2 skipped", "muted")]
assert host_recap({"ok": 1, "failed": 1, "unreachable": 1}) == [
("unreachable", "crit"), ("1 failed", "crit"), ("1 ok", None)]
assert host_recap({"changed": 2}, check=True) == [("2 would change", None)]
async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
now = datetime.now(timezone.utc)
run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600)
hosts = hosts_worst_first({"hosts": {
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}})
async with app.test_request_context("/ansible/runs/dddd4444"):
session["user_role"] = "operator"
page = await render_template(
"ansible/run_detail.html", run=run, row=run_view(run, {"steward:target:t1": "target web-1"}, now),
origin={"text": "schedule nightly", "url": "/ansible/schedules", "kind": "schedule"},
hosts=hosts_worst_first({"hosts": {
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}}))
hosts=[(h, host_recap(c)) for h, c in hosts])
assert "<title>update.yml on web-1 — Steward</title>" in page
assert 'href="/ansible/"' in page # breadcrumb back to the runs
assert '<span class="badge badge-warn">Interrupted</span>' in page
assert "<dd>10m</dd>" in page
# One verdict line (#5330): how it ended, how long, who started it.
verdict = page[page.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
assert '<span class="badge badge-warn">Interrupted</span>' in verdict and " in 10m" in verdict
assert 'by <a href="/ansible/schedules">schedule nightly</a>' in verdict
assert "/cancel" not in page # an interrupted run has nothing to cancel
# #5212: who started it, by name; no run id in the page; the failed host first.
assert '<dd><a href="/ansible/schedules">schedule nightly</a></dd>' in page
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "")
# Each host's result in words, the failed host first.
assert page.index(">zulu<") < page.index(">alpha<")
assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page
assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran
main = page[page.index("<main"):]
assert 'style="' not in main[:main.index('id="run-output"')] # no hand-set colours
assert not hex_colours(main), hex_colours(main)
@@ -0,0 +1,85 @@
"""Integration: the Ansible Runs tab (#5329) against real Postgres. Its
verdict counts the last 30 days; the filter bar narrows by playbook, status
and origin; an HTMX request from the bar gets the table alone, and the next
page as the reader scrolls keeps the filters. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_the_runs_tab_counts_and_filters(app):
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
now = datetime.now(timezone.utc)
def run(name, status, mins, origin=None, by=None, results=None):
return AnsibleRun(playbook_path=f"it{tag}/{name}.yml", source_name="steward-builtin",
inventory_scope="steward:all", status=status, origin=origin,
triggered_by=by, started_at=now - timedelta(minutes=mins),
finished_at=now - timedelta(minutes=mins - 1), results=results)
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"runs-{tag}", email=f"runs-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
async with s.begin():
s.add_all([
run("prune", AnsibleRunStatus.success, 10, origin="schedule",
results={"hosts": {"a": {"ok": 2, "changed": 1}, "b": {"ok": 2, "changed": 1}}}),
run("update", AnsibleRunStatus.failed, 20, by=uid,
results={"hosts": {f"db-{tag}": {"ok": 1, "failed": 1}}}),
])
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"runs-{tag}", login_at=time.time())
async def get(url, **kw):
resp = await client.get(url, **kw)
return resp.status_code, await resp.get_data(as_text=True)
out = {"page": await get(f"/ansible/?q=it{tag}"),
"failed": await get(f"/ansible/?q=it{tag}&status=failed", headers={"HX-Request": "true"}),
"sched": await get(f"/ansible/?q=it{tag}&origin=schedule", headers={"HX-Request": "true"}),
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"})}
async with app.db_sessionmaker() as s:
async with s.begin():
from sqlalchemy import delete
await s.execute(delete(AnsibleRun).where(AnsibleRun.playbook_path.like(f"it{tag}/%")))
await s.delete(await s.get(User, uid))
return tag, out
tag, out = asyncio.run(_go())
status, page = out["page"]
assert status == 200
assert '<p class="page-verdict">' in page and "in the last 30 days" in page # the verdict queries ran
assert f"it{tag}/prune.yml" in page and f"it{tag}/update.yml" in page
assert "2 hosts · 2 changed" in page and f'<span class="tone-crit">failed on db-{tag}</span>' in page
assert '<nav class="section-tabs" aria-label="Ansible">' in page
failed = out["failed"][1]
assert "<html" not in failed and '<p class="page-verdict">' not in failed # the table alone
assert f"it{tag}/update.yml" in failed and f"it{tag}/prune.yml" not in failed
sched = out["sched"][1]
assert f"it{tag}/prune.yml" in sched and f"it{tag}/update.yml" not in sched
person = out["person"][1]
assert f"it{tag}/update.yml" in person and f"it{tag}/prune.yml" not in person