feat(ansible): schedules read as sentences, each with its own page (#5332)
- 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:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user