From a02f224f221047fc5721f93760bb5f600a6ae473 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 23:45:04 -0400 Subject: [PATCH] feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- steward/ansible/recap.py | 63 +++++++++ steward/ansible/routes.py | 117 +++++++++++++++-- steward/static/steward.css | 12 ++ steward/templates/_macros.html | 11 ++ steward/templates/ansible/_runs_page.html | 27 ++-- steward/templates/ansible/_runs_table.html | 26 ++++ steward/templates/ansible/_tabs.html | 8 ++ steward/templates/ansible/browse.html | 4 +- steward/templates/ansible/index.html | 60 +++++---- .../ansible/inventory/group_detail.html | 2 + .../templates/ansible/inventory/groups.html | 2 + .../ansible/inventory/target_detail.html | 2 + .../templates/ansible/inventory/targets.html | 2 + .../templates/ansible/playbook_editor.html | 2 + steward/templates/ansible/run_detail.html | 79 ++++++------ steward/templates/ansible/schedules.html | 6 +- tests/core/test_ansible_runs_page.py | 122 +++++++++++++++--- tests/integration/test_ansible_runs_list.py | 85 ++++++++++++ 18 files changed, 519 insertions(+), 111 deletions(-) create mode 100644 steward/ansible/recap.py create mode 100644 steward/templates/ansible/_runs_table.html create mode 100644 steward/templates/ansible/_tabs.html create mode 100644 tests/integration/test_ansible_runs_list.py diff --git a/steward/ansible/recap.py b/steward/ansible/recap.py new file mode 100644 index 0000000..b05eb6e --- /dev/null +++ b/steward/ansible/recap.py @@ -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 diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index c9badea..ff1f32b 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -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= 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= 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]]: diff --git a/steward/static/steward.css b/steward/static/steward.css index a7459cc..e267940 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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:
…
…
. */ .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). */ diff --git a/steward/templates/_macros.html b/steward/templates/_macros.html index a59cce9..771cc01 100644 --- a/steward/templates/_macros.html +++ b/steward/templates/_macros.html @@ -73,6 +73,17 @@ {%- 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) -%} + +{%- 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") -%} diff --git a/steward/templates/ansible/_runs_page.html b/steward/templates/ansible/_runs_page.html index 2c479ec..93460af 100644 --- a/steward/templates/ansible/_runs_page.html +++ b/steward/templates/ansible/_runs_page.html @@ -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 %} - - {{ row.title }} - {% if row.check %}dry run{% endif %} -
{{ run.source_name }}
+ + {{ row.title }} +
{{ run.source_name }}{% if row.check %} · dry run{% endif %}
- {{ status_badge(run.status) }} - {% if row.origin %}{% if row.origin.url %}{{ row.origin.text }}{% else %}{{ row.origin.text }}{% endif %}{% endif %} - {{ run.started_at|ago }} - {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} + + {{ status_badge(run.status) }} + {%- if row.recap %} +
{% for text, tone in row.recap %}{% if not loop.first %} · {% endif %}{% if tone %}{{ text }}{% else %}{{ text }}{% endif %}{% endfor %}
+ {%- endif %} + + {% if row.origin %}{{ row.origin.text }}{% endif %} + {{ run.started_at|ago }} + {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} {% 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 %} diff --git a/steward/templates/ansible/_runs_table.html b/steward/templates/ansible/_runs_table.html new file mode 100644 index 0000000..51b9f1b --- /dev/null +++ b/steward/templates/ansible/_runs_table.html @@ -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 %} +
+ {%- 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 -%} +
+{% else %} +
+ + + + + + + + + + + + {% include "ansible/_runs_page.html" %} + +
RunResultStarted byStartedTook
+
+{% endif %} diff --git a/steward/templates/ansible/_tabs.html b/steward/templates/ansible/_tabs.html new file mode 100644 index 0000000..eb35704 --- /dev/null +++ b/steward/templates/ansible/_tabs.html @@ -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 %} diff --git a/steward/templates/ansible/browse.html b/steward/templates/ansible/browse.html index de61c3b..916a59e 100644 --- a/steward/templates/ansible/browse.html +++ b/steward/templates/ansible/browse.html @@ -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") }}

Browse Playbooks

{% if session.user_role == 'admin' %} New playbook {% endif %} - Inventory - Schedules
diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index 416b150..9177840 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -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.") %} - Inventory - Schedules - Browse Playbooks +{{ ansible_tabs("runs") }} +{% call page_header("Ansible Runs") %} + Run a playbook {% endcall %} -{% if not runs %} -
{{ empty_state("No runs yet.", "Browse playbooks", "/ansible/browse") }}
-{% else %} -
- - - - - - - - - - - - {% include "ansible/_runs_page.html" %} - -
RunStatusStarted byStartedDuration
-
-{% endif %} + +{% set v = verdict %} +

+ {%- if not v.total %}No runs in the last {{ v.days }} days + {%- elif v.failed %}{{ v.failed }} of {{ v.total }} run{{ 's' if v.total != 1 }} failed in the last {{ v.days }} days + {%- if v.latest_failed %} — latest {{ v.latest_failed.title }} {{ v.latest_failed.run.started_at|ago }}{% endif %} + {%- else %}{{ v.total }} run{{ 's' if v.total != 1 }} in the last {{ v.days }} days, {{ 'all' if v.total > 1 else 'it' }} succeeded{% endif %} + {%- if v.running %} · {{ v.running }} running now{% endif %} + {%- if v.next %} · next: {{ v.next.name }} {% if v.next.at %}{{ v.next.at|ago }}{% else %}due now{% endif %}{% endif -%} +

+ + +
{% include "ansible/_runs_table.html" %}
{% endblock %} diff --git a/steward/templates/ansible/inventory/group_detail.html b/steward/templates/ansible/inventory/group_detail.html index 2e21ec6..c5a644e 100644 --- a/steward/templates/ansible/inventory/group_detail.html +++ b/steward/templates/ansible/inventory/group_detail.html @@ -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") }}

Group: {{ group.name }}

diff --git a/steward/templates/ansible/inventory/groups.html b/steward/templates/ansible/inventory/groups.html index c271db6..7a36488 100644 --- a/steward/templates/ansible/inventory/groups.html +++ b/steward/templates/ansible/inventory/groups.html @@ -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") }}

Inventory Groups

diff --git a/steward/templates/ansible/inventory/target_detail.html b/steward/templates/ansible/inventory/target_detail.html index e6e4754..8abe130 100644 --- a/steward/templates/ansible/inventory/target_detail.html +++ b/steward/templates/ansible/inventory/target_detail.html @@ -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") }}

Target: {{ target.name }}

diff --git a/steward/templates/ansible/inventory/targets.html b/steward/templates/ansible/inventory/targets.html index a909aee..f1bffa8 100644 --- a/steward/templates/ansible/inventory/targets.html +++ b/steward/templates/ansible/inventory/targets.html @@ -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") }}

Inventory Targets

diff --git a/steward/templates/ansible/playbook_editor.html b/steward/templates/ansible/playbook_editor.html index bcb05c1..505fae5 100644 --- a/steward/templates/ansible/playbook_editor.html +++ b/steward/templates/ansible/playbook_editor.html @@ -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") }}

{{ "Edit playbook" if editing else "New playbook" }}

diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index 5b25cb8..dda79a9 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -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 %}{{ text }}{% 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") %}
{{ csrf_field() }} @@ -12,15 +20,19 @@ Download log {% endcall %} +

+ {{- status_badge(run.status) }} + {%- if row.check %} dry run{% 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 %}{{ origin.text }}{% else %}{{ origin.text }}{% endif %} + {%- if row.recap %} · {{ words(row.recap) }}{% endif -%} +

+
-
Status
{{ status_badge(run.status) }}{% if row.check %} dry run{% endif %}
-
Started
{{ run.started_at|ago }}
-
Duration
{{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}
-
Playbook
{{ run.playbook_path }}
-
Scope
{{ row.scope_label }}
-
Source
{{ run.source_name }}
-
Started by
{% if origin.url %}{{ origin.text }}{% else %}{{ origin.text }}{% endif %}
+
Playbook
{{ run.playbook_path }} from {{ run.source_name }}
+
On
{{ row.scope_label }}
{% if run.params %} {% if run.params.limit %}
Limit
{{ run.params.limit }}
{% endif %} {% if run.params.tags %}
Tags
{{ run.params.tags }}
{% endif %} @@ -30,45 +42,30 @@
{% if hosts %} -
-
Host summary
+
+

Hosts worst first

- - - - - - + - {% for host, c in hosts %} - - - - - - - - + {% for host, parts in hosts %} + {% endfor %}
Hostokchangedunreachablefailedskipped
HostResult
{{ host }}{{ c.ok }}{{ c.changed }}{{ c.unreachable }}{{ c.failed }}{{ c.skipped }}
{{ host }}{{ words(parts) if parts else '—' }}
- {% if run.results.failures %} -
Failures
-
{{ run.results.failures | join("\n") }}
- {% endif %} -
+ +{% endif %} +{% if run.results and run.results.failures %} +
+

What failed

+
{{ run.results.failures | join("\n") }}
+
{% endif %} -
-
- Output -
+
+

Output

{% if run.status.value in ("running", "queued") %} -
{{ run.output or "" }}
-
- Streaming… -
+
{{ run.output or "" }}
+

Streaming…

{% else %} -
{{ run.output or "(no output)" }}
+
{{ run.output or "(no output)" }}
{% endif %} -
+ {% endblock %} diff --git a/steward/templates/ansible/schedules.html b/steward/templates/ansible/schedules.html index 8b7958c..417f69c 100644 --- a/steward/templates/ansible/schedules.html +++ b/steward/templates/ansible/schedules.html @@ -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') %}

Ansible Schedules

-
{# ── Existing schedules ───────────────────────────────────────────────────── #} diff --git a/tests/core/test_ansible_runs_page.py b/tests/core/test_ansible_runs_page.py index 66f3620..c7338f2 100644 --- a/tests/core/test_ansible_runs_page.py +++ b/tests/core/test_ansible_runs_page.py @@ -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("' in main # the whole row opens the run - assert 'update.yml on web-1' in main + assert ('' + 'update.yml on web-1') in main assert "steward:target:t1" not in main # no raw scope, not even as a tooltip assert ">update.yml on all inventory targets" in main assert 'Running' in main assert 'Failed' in main assert 'Succeeded' in main + # Under each status, what the run did across its hosts (#5329). + assert 'failed on db-1 · 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('")] + 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 'Runs' 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 '33 runs in the last 30 days, all succeeded' in ok + assert 'next: Nightly prune in 2h" in ok + assert '2 of 33 runs failed in the last 30 days' in worse + assert 'latest update.yml on web-1' in worse + assert '1 running now' 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 "update.yml on web-1 — Steward" in page assert 'href="/ansible/"' in page # breadcrumb back to the runs - assert 'Interrupted' in page - assert "
10m
" in page + # One verdict line (#5330): how it ended, how long, who started it. + verdict = page[page.index('

'):] + verdict = verdict[:verdict.index("

")] + assert 'Interrupted' in verdict and " in 10m" in verdict + assert 'by schedule nightly' 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 '
schedule nightly
' 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 '1 failed · 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("' 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'failed on db-{tag}' in page + assert '