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
+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 ───────────────────────────────────────────────────── #}