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
@@ -24,7 +24,7 @@ def describe_origin(run, users: dict, schedules: dict, rules: dict) -> dict:
text = f"schedule {name}" if name else "a deleted schedule"
if who:
text += f" (run now by {who})"
return {"text": text, "url": "/ansible/schedules" if name else None, "kind": "schedule"}
return {"text": text, "url": f"/ansible/schedules/{run.origin_id}" if name else None, "kind": "schedule"}
if run.origin == "alert":
name = rules.get(run.origin_id)
return {"text": f"alert rule {name}" if name else "a deleted alert rule",
+60 -46
View File
@@ -71,7 +71,7 @@ def filter_runs(stmt, f: dict):
def next_scheduled(schedules, now: datetime) -> dict | None:
"""The enabled schedule due soonest: {"name", "at"}, `at` None when it is
"""The enabled schedule due soonest: {"id", "name", "at"}, `at` None when it is
due now (it has never run, or its turn has passed)."""
best = None
for s in schedules:
@@ -82,7 +82,7 @@ def next_scheduled(schedules, now: datetime) -> dict | None:
at = None
key = at or now
if best is None or key < best[0]:
best = (key, {"name": s.name, "at": at})
best = (key, {"id": s.id, "name": s.name, "at": at})
return best[1] if best else None
@@ -529,67 +529,81 @@ async def run_log(run_id: str):
@ansible_bp.get("/schedules")
@require_role(UserRole.viewer)
async def schedules():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
editing_id = request.args.get("edit")
"""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; the row opens its own page."""
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
scheds = (await db.execute(
select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars().all()
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
run_ids = [s.last_run_id for s in scheds if s.last_run_id]
runs = {
r.id: r for r in (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all()
} if run_ids else {}
labels = await scope_labels(db, [s.inventory_scope for s in scheds])
rows = [{"s": s, "last_run": runs.get(s.last_run_id) if s.last_run_id else None,
"scope_label": labels.get(s.inventory_scope, s.inventory_scope),
"next": next_scheduled([s], now)} for s in scheds]
rows.sort(key=_schedule_order)
return await render_template(
"ansible/schedules.html", rows=rows, next=next_scheduled(scheds, now),
failing=sum(1 for r in rows if _schedule_order(r)[0] == 0),
paused=sum(1 for s in scheds if not s.enabled))
@ansible_bp.get("/schedules/new")
@ansible_bp.get("/schedules/<schedule_id>")
@require_role(UserRole.viewer)
async def schedule_page(schedule_id: str | None = None):
"""A schedule's own page (#5332): its form (operators), and at the foot
Delete behind the confirm dialog. /schedules/new is the empty form;
?source=&playbook= start it on a playbook ("Schedule it" on its page)."""
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
async with current_app.db_sessionmaker() as db:
editing = await db.get(AnsibleSchedule, schedule_id) if schedule_id else None
if schedule_id and editing is None:
return "Schedule not found", 404
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
last_run = await db.get(AnsibleRun, editing.last_run_id) if editing and editing.last_run_id else None
labels = await scope_labels(db, [editing.inventory_scope] if editing else [])
source_data = [
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
for s in _get_sources()
]
want_source = editing.source_name if editing else request.args.get("source", "")
want_playbook = editing.playbook_path if editing else request.args.get("playbook", "")
if not any(sd["name"] == want_source for sd in source_data):
want_source = source_data[0]["name"] if source_data else ""
sel_playbooks = next((sd["playbooks"] for sd in source_data if sd["name"] == want_source), [])
if want_playbook not in sel_playbooks:
want_playbook = sel_playbooks[0] if sel_playbooks else ""
rows = []
editing = None
for s in scheds:
rows.append({
"s": s,
"last_run": runs.get(s.last_run_id) if s.last_run_id else None,
"scope_label": labels.get(s.inventory_scope, s.inventory_scope),
"next_run": (s.last_run_at + timedelta(seconds=s.interval_seconds)) if s.last_run_at else None,
})
if editing_id and s.id == editing_id:
editing = s
rows.sort(key=_schedule_order)
# Default the source dropdown to the first source (or the edited schedule's
# source), and pre-render that source's playbooks so the playbook dropdown
# starts on a real item rather than a placeholder.
sel_source = editing.source_name if editing else (source_data[0]["name"] if source_data else "")
sel_playbooks = next((sd["playbooks"] for sd in source_data if sd["name"] == sel_source), [])
# Edit mode: pre-render the selected playbook's declared variables so their
# saved values show (fresh loads fetch these via HTMX from /playbook-vars).
edit_variables: list = []
edit_meta: dict = {"description": "", "category": "", "confirm": False}
if editing:
src = next((s for s in _get_sources() if s["name"] == editing.source_name), None)
if src:
contents = src_module.read_playbook(src["path"], editing.playbook_path)
if contents is not None:
edit_variables = src_module.discover_playbook_variables(contents)
edit_meta = src_module.discover_playbook_meta(contents)
# The chosen playbook's declared variables, rendered with the page so a
# saved schedule's values show (choosing another playbook reloads them
# through /playbook-vars).
variables: list = []
meta: dict = {"description": "", "category": "", "confirm": False}
src = _source(want_source)
if src and want_playbook:
contents = src_module.read_playbook(src["path"], want_playbook)
if contents is not None:
variables = src_module.discover_playbook_variables(contents)
meta = src_module.discover_playbook_meta(contents)
return await render_template(
"ansible/schedules.html",
rows=rows, groups=groups, targets=targets,
source_data=source_data, sel_source=sel_source, sel_playbooks=sel_playbooks,
editing=editing, interval_presets=INTERVAL_PRESETS,
edit_variables=edit_variables, edit_description=edit_meta["description"],
edit_category=edit_meta["category"], edit_confirm=edit_meta["confirm"],
)
"ansible/schedule.html", editing=editing, groups=groups, targets=targets,
source_data=source_data, sel_source=want_source, sel_playbook=want_playbook,
sel_playbooks=sel_playbooks, interval_presets=INTERVAL_PRESETS,
edit_variables=variables, edit_description=meta["description"],
edit_category=meta["category"], edit_confirm=meta["confirm"], last_run=last_run,
scope_label=labels.get(editing.inventory_scope) if editing else None,
next=next_scheduled([editing], datetime.now(timezone.utc)) if editing else None)
def _schedule_order(row: dict) -> tuple:
+1
View File
@@ -382,6 +382,7 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.field-check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: normal; color: var(--text-muted); }
.form-actions .field-check { margin: 0; }
.playbook-cell { max-width: 30rem; }
.inline-form { display: inline; }
/* The playbook itself, folded under its page. */
.playbook-source > summary { cursor: pointer; margin: 0; }
.playbook-source[open] > summary { margin-bottom: 0.75rem; }
+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 %}