feat(ansible): schedules read as sentences, each with its own page (#5332)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 59s
CI / unit (push) Failing after 1m22s
CI / publish (push) Skipped

- The Schedules tab: a verdict line (failing first, paused, next due),
  each schedule as one line (what runs where, how often, when next, how it
  last went); the row opens its page; Run now and Pause stay in the row.
- /ansible/schedules/new and /ansible/schedules/<id>: the form on classes,
  dry run beside the button, the playbook's variables rendered with the
  page; at the foot Delete behind the confirm dialog. ?source=&playbook=
  start a new one on a playbook (Schedule it on the playbook's page).
- A run started by a schedule links to that schedule's page.
- The run page's id guard allows the Run again link's ?from=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:51:15 -04:00
co-authored by Claude Opus 5.5
parent 72a3261582
commit 0c72892f58
11 changed files with 348 additions and 211 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
{%- 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 -%}
{%- if v.next %} · next: <a href="/ansible/schedules/{{ v.next.id }}">{{ 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"
+2 -2
View File
@@ -40,13 +40,13 @@
<section class="card-flush" aria-labelledby="pb-schedules">
<h2 id="pb-schedules" class="table-caption">Schedules
{%- if schedules %} <span class="tone-muted">{{ schedules|length }}</span>{% endif %}
{%- if can_run %} <a class="field-hint" href="/ansible/schedules">Schedule it →</a>{% endif %}</h2>
{%- if can_run %} <a class="field-hint" href="/ansible/schedules/new?source={{ source_name|urlencode }}&amp;playbook={{ playbook_path|urlencode }}">Schedule it →</a>{% endif %}</h2>
{% if schedules %}
<table class="table">
<tbody>
{% for row in schedules %}{% set s = row.s %}
<tr class="row-link{% if not s.enabled %} row-off{% endif %}">
<td class="cell-name"><a class="row-target" href="/ansible/schedules?edit={{ s.id }}#form">{{ s.name }}</a></td>
<td class="cell-name"><a class="row-target" href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a></td>
<td>on {{ row.scope_label }} · {{ interval_words(s.interval_seconds) }}</td>
<td class="nowrap">{% if not s.enabled %}<span class="tone-dim">paused</span>
{%- elif row.next and row.next.at %}next {{ row.next.at|ago }}{% else %}due now{% endif %}</td>
+141
View File
@@ -0,0 +1,141 @@
{# ansible/schedule.html — one schedule's page (#5332): when it runs next and
how it last went, its form (operators), and at the foot Delete behind the
confirm dialog. /ansible/schedules/new is the same page, empty. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "ansible/_macros.html" import interval_words %}
{% block title %}{{ editing.name if editing else "New schedule" }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Schedules", "/ansible/schedules"), (editing.name if editing else "New schedule", "")]) }}{% endblock %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{{ ansible_tabs("schedules") }}
{% call page_header(editing.name if editing else "New schedule") %}
{% if editing and can_edit %}
<form method="post" action="/ansible/schedules/{{ editing.id }}/run-now">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form>
{% endif %}
{% endcall %}
{% if editing %}
<p class="page-verdict">
<a href="/ansible/playbooks/{{ editing.source_name }}/{{ editing.playbook_path }}" class="mono">{{ editing.playbook_path }}</a>
on {{ scope_label }} · {{ interval_words(editing.interval_seconds) }}
{%- if (editing.params or {}).get('check') %} · <span class="tone-info">dry run</span>{% endif %}
· {% if not editing.enabled %}<span class="tone-dim">paused</span>{% elif next and next.at %}next {{ next.at|ago }}{% else %}due now{% endif %}
{%- if last_run %} · last: <a href="/ansible/runs/{{ last_run.id }}">{{ status_badge(last_run.status) }}</a> {{ last_run.started_at|ago }}{% elif not editing.last_error %} · never run{% endif %}
{%- if editing.last_error %} · <span class="{{ 'tone-muted' if last_run else 'tone-crit' }}">{{ editing.last_error }}</span>{% endif -%}
</p>
{% endif %}
{# ── Create / edit form (operators and admins) ───────────────────────────── #}
{% if can_edit %}
{% set p = (editing.params or {}) if editing else {} %}
{% set saved_vars = p.get('extra_vars_map') or {} %}
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
<div class="card" id="form">
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">{{ csrf_field() }}
<div class="form-pair">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" required value="{{ editing.name if editing else '' }}" placeholder="Nightly docker prune">
</div>
<div class="form-group">
<label>Source</label>
<select name="source_name" required
hx-get="/ansible/playbook-options" hx-trigger="change"
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this"
hx-on::after-settle="htmx.trigger('#sch-playbook','change')">
{% for sd in source_data %}
<option value="{{ sd.name }}" {% if sd.name == sel_source %}selected{% endif %}>{{ sd.name }}</option>
{% endfor %}
{% if not source_data %}<option value="" disabled>No Ansible sources — add one in Settings</option>{% endif %}
</select>
</div>
<div class="form-group">
<label>Playbook</label>
<select name="playbook_path" id="sch-playbook" required
hx-get="/ansible/playbook-vars?schedule=1"
hx-trigger="change"
hx-target="#sch-playbook-vars" hx-swap="innerHTML" hx-include="closest form">
{% for pb in sel_playbooks %}
<option value="{{ pb }}" {% if pb == sel_playbook %}selected{% endif %}>{{ pb }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Target</label>
<select name="inventory_scope">
<option value="steward:all" {% if editing and editing.inventory_scope == 'steward:all' %}selected{% endif %}>All targets</option>
{% for g in groups %}
<option value="steward:group:{{ g.id }}" {% if editing and editing.inventory_scope == 'steward:group:' ~ g.id %}selected{% endif %}>Group: {{ g.name }}</option>
{% endfor %}
{% for t in targets %}
<option value="steward:target:{{ t.id }}" {% if editing and editing.inventory_scope == 'steward:target:' ~ t.id %}selected{% endif %}>Target: {{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Interval</label>
<select name="interval_seconds" required>
{% for secs, label in interval_presets %}
<option value="{{ secs }}" {% if editing and editing.interval_seconds == secs %}selected{% elif not editing and secs == 86400 %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Limit (optional)</label>
<input type="text" name="limit" value="{{ p.get('limit', '') }}" placeholder="host or group pattern">
</div>
<div class="form-group">
<label>Tags (optional)</label>
<input type="text" name="tags" value="{{ p.get('tags', '') }}" placeholder="tag1,tag2">
</div>
</div>
{# Declared playbook variables (vars:/vars_prompt:) — loaded via HTMX on
playbook change; pre-rendered here in edit mode so saved values show. #}
<div id="sch-playbook-vars">
{% if sel_playbook %}
{% set variables = edit_variables %}
{% set description = edit_description %}
{% set category = edit_category %}
{% set confirm = edit_confirm %}
{% set values = saved_vars %}
{% set schedule = true %}
{% include "ansible/_playbook_vars.html" %}
{% endif %}
</div>
<details class="run-more">
<summary>Extra vars</summary>
<div class="form-group">
<label for="sch-extra">Extra vars <span class="field-hint">one key=value per line, for vars not listed above</span></label>
<textarea id="sch-extra" name="extra_vars" rows="3" class="mono" placeholder="prune_volumes=false">{% for k, v in saved_vars.items() if k not in declared_names %}{{ k }}={{ v }}
{% endfor %}</textarea>
</div>
</details>
<div class="form-actions">
<button type="submit" class="btn">{{ "Save schedule" if editing else "Create schedule" }}</button>
<a href="/ansible/schedules" class="btn btn-ghost">Cancel</a>
<label class="field-check">
<input type="checkbox" name="check"{% if p.get('check') %} checked{% endif %}>
Dry run each time: show what would change, change nothing
</label>
</div>
</form>
</div>
{% endif %}
{% if editing and can_edit %}
<section class="danger-zone" aria-labelledby="sched-delete">
<h2 id="sched-delete" class="section-title">Delete this schedule</h2>
<p class="field-hint">Its past runs stay in the Runs list. Pausing it keeps it.</p>
<form method="post" action="/ansible/schedules/{{ editing.id }}/delete"
data-confirm="Delete schedule {{ editing.name }}? It stops running; its past runs stay in the list. Pausing it keeps it.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete schedule</button>
</form>
</section>
{% endif %}
{% endblock %}
+38 -150
View File
@@ -1,74 +1,62 @@
{# ansible/schedules.html — the Schedules tab (#5332): a verdict line, then
each schedule as one sentence (what runs where, how often, when next, how
it last went), failing first and paused last. The whole row opens the
schedule's page; Run now and Pause stay in the row. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %}
{% from "ansible/_tabs.html" import ansible_tabs %}
{% from "_macros.html" import crumbs, status_badge %}
{% from "ansible/_macros.html" import interval_words %}
{% block title %}Ansible Schedules — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Schedules", "")]) }}{% endblock %}
{% macro fmt_interval(secs) %}
{%- set m = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%}
{{ m.get(secs) or ("every " ~ (secs|duration)) }}
{% 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>
{{ ansible_tabs("schedules") }}
{% call page_header("Schedules") %}
{% if can_edit %}<a href="/ansible/schedules/new" class="btn">New schedule</a>{% endif %}
{% endcall %}
{# ── Existing schedules ───────────────────────────────────────────────────── #}
{% if not rows %}
<div class="card empty">No schedules yet.{% if can_edit %} Create one below to run a playbook on a recurring interval.{% endif %}</div>
<div class="card">{{ empty_state("Nothing runs on a schedule yet.", "New schedule" if can_edit else none, "/ansible/schedules/new" if can_edit else none) }}</div>
{% else %}
<p class="page-verdict">
{%- if failing %}<span class="tone-crit">{{ failing }} of {{ rows|length }} failing</span>
{%- else %}<span class="tone-ok">{{ rows|length }} schedule{{ 's' if rows|length != 1 }}, {{ 'none' if rows|length > 1 else 'not' }} failing</span>{% endif %}
{%- if paused %} · {{ paused }} paused{% endif %}
{%- if next %} · next: <a href="/ansible/schedules/{{ next.id }}">{{ next.name }}</a> {% if next.at %}{{ next.at|ago }}{% else %}due now{% endif %}{% endif -%}
</p>
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Name</th><th>Playbook</th><th>Target</th><th>Interval</th>
<th>Last run</th><th>Next run</th><th></th>
</tr>
</thead>
<thead><tr><th>Schedule</th><th>When</th><th>Last run</th><th></th></tr></thead>
<tbody>
{% for row in rows %}
{% set s = row.s %}
<tr style="{% if not s.enabled %}opacity:0.55;{% endif %}">
<td style="font-weight:500;">{{ s.name }}</td>
<td style="font-size:0.85rem;"><span style="color:var(--text-dim);">{{ s.source_name }}</span> / {{ s.playbook_path }}</td>
<td style="font-size:0.85rem;" title="{{ s.inventory_scope }}">{{ row.scope_label }}</td>
<td style="font-size:0.85rem;">{{ fmt_interval(s.interval_seconds) }}</td>
<td style="font-size:0.82rem;">
{% if row.last_run %}
<a href="/ansible/runs/{{ row.last_run.id }}" style="text-decoration:none;">{{ status_badge(row.last_run.status) }}</a>
<span style="color:var(--text-dim);">· {{ row.last_run.started_at|ago }}</span>
{% endif %}
{% if s.last_error %}
{# With a run beside it, the last firing was skipped because that run was
still going (#5158); without one, the firing couldn't start a run. #}
<div style="color:{{ 'var(--text-muted)' if row.last_run else 'var(--red)' }};">
{{ s.last_error }} · {{ s.last_run_at|ago }}
</div>
{% elif not row.last_run %}
<span style="color:var(--text-dim);">never</span>
{% endif %}
<tr class="row-link{% if not s.enabled %} row-off{% endif %}">
<td class="cell-name playbook-cell">
<a class="row-target" href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a>
<div class="cell-sub"><span class="mono">{{ s.playbook_path }}</span> on {{ row.scope_label }}
{%- if (s.params or {}).get('check') %} · <span class="tone-info">dry run</span>{% endif %}</div>
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% if not s.enabled %}<span style="color:var(--text-dim);">paused</span>
{% elif row.next_run %}{{ row.next_run|ago }}
{% else %}due now{% endif %}
<td class="nowrap">
{{ interval_words(s.interval_seconds) }}
<div class="cell-sub">{% if not s.enabled %}paused{% elif row.next and row.next.at %}next {{ row.next.at|ago }}{% else %}due now{% endif %}</div>
</td>
<td class="td-actions" style="white-space:nowrap;">
<td>
{%- if row.last_run %}{{ status_badge(row.last_run.status) }} <span class="tone-muted">{{ row.last_run.started_at|ago }}</span>
{%- elif not s.last_error %}<span class="tone-dim">never run</span>{% endif %}
{%- if s.last_error %}
{#- With a run beside it, the last firing was skipped because that run was
still going (#5158); without one, the firing couldn't start a run. #}
<div class="cell-sub {{ 'tone-muted' if row.last_run else 'tone-crit' }}" title="{{ s.last_error }}">{{ s.last_error }} · {{ s.last_run_at|ago }}</div>
{%- endif %}
</td>
<td class="td-actions">
{% if can_edit %}
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">{{ csrf_field() }}
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" class="inline-form">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form>
<form method="post" action="/ansible/schedules/{{ s.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<form method="post" action="/ansible/schedules/{{ s.id }}/toggle" class="inline-form">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">{{ "Pause" if s.enabled else "Resume" }}</button>
</form>
<a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;"
data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
{% endif %}
</td>
</tr>
@@ -77,104 +65,4 @@
</table>
</div>
{% endif %}
{# ── Create / edit form (operators and admins) ───────────────────────────── #}
{% if can_edit %}
{% set p = (editing.params or {}) if editing else {} %}
{% set saved_vars = p.get('extra_vars_map') or {} %}
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
<div class="card" id="form">
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;">
<div class="form-group" style="margin-bottom:0;">
<label>Name</label>
<input type="text" name="name" required value="{{ editing.name if editing else '' }}" placeholder="Nightly docker prune">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Source</label>
<select name="source_name" required
hx-get="/ansible/playbook-options" hx-trigger="change"
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this"
hx-on::after-settle="htmx.trigger('#sch-playbook','change')">
{% for sd in source_data %}
<option value="{{ sd.name }}" {% if sd.name == sel_source %}selected{% endif %}>{{ sd.name }}</option>
{% endfor %}
{% if not source_data %}<option value="" disabled>No Ansible sources — add one in Settings</option>{% endif %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Playbook</label>
<select name="playbook_path" id="sch-playbook" required
hx-get="/ansible/playbook-vars?schedule=1"
hx-trigger="{% if not editing %}load, {% endif %}change"
hx-target="#sch-playbook-vars" hx-swap="innerHTML" hx-include="closest form">
{% for pb in sel_playbooks %}
<option value="{{ pb }}" {% if editing and editing.playbook_path == pb %}selected{% endif %}>{{ pb }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Target</label>
<select name="inventory_scope">
<option value="steward:all" {% if editing and editing.inventory_scope == 'steward:all' %}selected{% endif %}>All targets</option>
{% for g in groups %}
<option value="steward:group:{{ g.id }}" {% if editing and editing.inventory_scope == 'steward:group:' ~ g.id %}selected{% endif %}>Group: {{ g.name }}</option>
{% endfor %}
{% for t in targets %}
<option value="steward:target:{{ t.id }}" {% if editing and editing.inventory_scope == 'steward:target:' ~ t.id %}selected{% endif %}>Target: {{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Interval</label>
<select name="interval_seconds" required>
{% for secs, label in interval_presets %}
<option value="{{ secs }}" {% if editing and editing.interval_seconds == secs %}selected{% elif not editing and secs == 86400 %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Limit (optional)</label>
<input type="text" name="limit" value="{{ p.get('limit', '') }}" placeholder="host or group pattern">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Tags (optional)</label>
<input type="text" name="tags" value="{{ p.get('tags', '') }}" placeholder="tag1,tag2">
</div>
</div>
{# Declared playbook variables (vars:/vars_prompt:) — loaded via HTMX on
playbook change; pre-rendered here in edit mode so saved values show. #}
<div id="sch-playbook-vars" style="margin-top:1rem;">
{% if editing %}
{% set variables = edit_variables %}
{% set description = edit_description %}
{% set category = edit_category %}
{% set confirm = edit_confirm %}
{% set values = saved_vars %}
{% set schedule = true %}
{% include "ansible/_playbook_vars.html" %}
{% endif %}
</div>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Extra vars</summary>
<div class="form-group" style="margin-top:0.6rem;">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line — for vars not listed above)</span></label>
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in saved_vars.items() if k not in declared_names %}{{ k }}={{ v }}
{% endfor %}</textarea>
</div>
</details>
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox" name="check" id="check" style="width:auto;" {% if p.get('check') %}checked{% endif %}>
<label for="check" style="margin-bottom:0;">Dry run (--check --diff)</label>
</div>
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn">{{ "Save schedule" if editing else "Create schedule" }}</button>
{% if editing %}<a href="/ansible/schedules" class="btn btn-ghost">Cancel</a>{% endif %}
</div>
</form>
</div>
{% endif %}
{% endblock %}