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" text = f"schedule {name}" if name else "a deleted schedule"
if who: if who:
text += f" (run now by {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": if run.origin == "alert":
name = rules.get(run.origin_id) name = rules.get(run.origin_id)
return {"text": f"alert rule {name}" if name else "a deleted alert rule", 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: 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).""" due now (it has never run, or its turn has passed)."""
best = None best = None
for s in schedules: for s in schedules:
@@ -82,7 +82,7 @@ def next_scheduled(schedules, now: datetime) -> dict | None:
at = None at = None
key = at or now key = at or now
if best is None or key < best[0]: 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 return best[1] if best else None
@@ -529,67 +529,81 @@ async def run_log(run_id: str):
@ansible_bp.get("/schedules") @ansible_bp.get("/schedules")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def schedules(): async def schedules():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup """The Schedules tab (#5332): a verdict line, then each schedule as one
sentence (what runs where, how often, when next, how it last went),
editing_id = request.args.get("edit") failing first; the row opens its own page."""
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
scheds = (await db.execute( scheds = (await db.execute(
select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars().all() 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] run_ids = [s.last_run_id for s in scheds if s.last_run_id]
runs = { runs = {
r.id: r for r in (await db.execute( r.id: r for r in (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all() select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all()
} if run_ids else {} } if run_ids else {}
labels = await scope_labels(db, [s.inventory_scope for s in scheds]) 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 = [ source_data = [
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])} {"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
for s in _get_sources() 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 = [] # The chosen playbook's declared variables, rendered with the page so a
editing = None # saved schedule's values show (choosing another playbook reloads them
for s in scheds: # through /playbook-vars).
rows.append({ variables: list = []
"s": s, meta: dict = {"description": "", "category": "", "confirm": False}
"last_run": runs.get(s.last_run_id) if s.last_run_id else None, src = _source(want_source)
"scope_label": labels.get(s.inventory_scope, s.inventory_scope), if src and want_playbook:
"next_run": (s.last_run_at + timedelta(seconds=s.interval_seconds)) if s.last_run_at else None, contents = src_module.read_playbook(src["path"], want_playbook)
}) if contents is not None:
if editing_id and s.id == editing_id: variables = src_module.discover_playbook_variables(contents)
editing = s meta = src_module.discover_playbook_meta(contents)
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)
return await render_template( return await render_template(
"ansible/schedules.html", "ansible/schedule.html", editing=editing, groups=groups, targets=targets,
rows=rows, groups=groups, targets=targets, source_data=source_data, sel_source=want_source, sel_playbook=want_playbook,
source_data=source_data, sel_source=sel_source, sel_playbooks=sel_playbooks, sel_playbooks=sel_playbooks, interval_presets=INTERVAL_PRESETS,
editing=editing, interval_presets=INTERVAL_PRESETS, edit_variables=variables, edit_description=meta["description"],
edit_variables=edit_variables, edit_description=edit_meta["description"], edit_category=meta["category"], edit_confirm=meta["confirm"], last_run=last_run,
edit_category=edit_meta["category"], edit_confirm=edit_meta["confirm"], 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: 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); } .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; } .form-actions .field-check { margin: 0; }
.playbook-cell { max-width: 30rem; } .playbook-cell { max-width: 30rem; }
.inline-form { display: inline; }
/* The playbook itself, folded under its page. */ /* The playbook itself, folded under its page. */
.playbook-source > summary { cursor: pointer; margin: 0; } .playbook-source > summary { cursor: pointer; margin: 0; }
.playbook-source[open] > summary { margin-bottom: 0.75rem; } .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 %} {%- 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 %} {%- 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.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> </p>
<form class="filter-bar" method="get" action="/ansible/" role="search" <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"> <section class="card-flush" aria-labelledby="pb-schedules">
<h2 id="pb-schedules" class="table-caption">Schedules <h2 id="pb-schedules" class="table-caption">Schedules
{%- if schedules %} <span class="tone-muted">{{ schedules|length }}</span>{% endif %} {%- 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 %} {% if schedules %}
<table class="table"> <table class="table">
<tbody> <tbody>
{% for row in schedules %}{% set s = row.s %} {% for row in schedules %}{% set s = row.s %}
<tr class="row-link{% if not s.enabled %} row-off{% endif %}"> <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>on {{ row.scope_label }} · {{ interval_words(s.interval_seconds) }}</td>
<td class="nowrap">{% if not s.enabled %}<span class="tone-dim">paused</span> <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> {%- 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" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %}
{% from "ansible/_tabs.html" import ansible_tabs %} {% 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 title %}Ansible Schedules — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Schedules", "")]) }}{% 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 %} {% block content %}
{{ ansible_tabs("schedules") }}
{% set can_edit = session.user_role in ('operator', 'admin') %} {% 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;"> {{ ansible_tabs("schedules") }}
<h1 class="page-title" style="margin-bottom:0;">Ansible Schedules</h1> {% call page_header("Schedules") %}
</div> {% if can_edit %}<a href="/ansible/schedules/new" class="btn">New schedule</a>{% endif %}
{% endcall %}
{# ── Existing schedules ───────────────────────────────────────────────────── #}
{% if not rows %} {% 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 %} {% 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"> <div class="card-flush">
<table class="table"> <table class="table">
<thead> <thead><tr><th>Schedule</th><th>When</th><th>Last run</th><th></th></tr></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>
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
{% set s = row.s %} {% set s = row.s %}
<tr style="{% if not s.enabled %}opacity:0.55;{% endif %}"> <tr class="row-link{% if not s.enabled %} row-off{% endif %}">
<td style="font-weight:500;">{{ s.name }}</td> <td class="cell-name playbook-cell">
<td style="font-size:0.85rem;"><span style="color:var(--text-dim);">{{ s.source_name }}</span> / {{ s.playbook_path }}</td> <a class="row-target" href="/ansible/schedules/{{ s.id }}">{{ s.name }}</a>
<td style="font-size:0.85rem;" title="{{ s.inventory_scope }}">{{ row.scope_label }}</td> <div class="cell-sub"><span class="mono">{{ s.playbook_path }}</span> on {{ row.scope_label }}
<td style="font-size:0.85rem;">{{ fmt_interval(s.interval_seconds) }}</td> {%- if (s.params or {}).get('check') %} · <span class="tone-info">dry run</span>{% endif %}</div>
<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 %}
</td> </td>
<td style="font-size:0.82rem;color:var(--text-muted);"> <td class="nowrap">
{% if not s.enabled %}<span style="color:var(--text-dim);">paused</span> {{ interval_words(s.interval_seconds) }}
{% elif row.next_run %}{{ row.next_run|ago }} <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>
{% else %}due now{% endif %}
</td> </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 %} {% 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> <button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form> </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> <button class="btn btn-sm btn-ghost" type="submit">{{ "Pause" if s.enabled else "Resume" }}</button>
</form> </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 %} {% endif %}
</td> </td>
</tr> </tr>
@@ -77,104 +65,4 @@
</table> </table>
</div> </div>
{% endif %} {% 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 %} {% endblock %}
+3 -2
View File
@@ -83,7 +83,7 @@ async def test_a_playbook_page_runs_it_and_shows_its_history(app):
session["user_role"] = "operator" session["user_role"] = "operator"
page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[ page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[
{"s": sched, "scope_label": "group Docker Swarm", {"s": sched, "scope_label": "group Docker Swarm",
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2)}}])) "next": {"id": "s1", "name": "Nightly prune", "at": now + timedelta(hours=2)}}]))
main = page[page.index("<main"):] main = page[page.index("<main"):]
assert 'href="/ansible/playbooks"' in page # crumbs back to the list assert 'href="/ansible/playbooks"' in page # crumbs back to the list
verdict = main[main.index('<p class="page-verdict">'):] verdict = main[main.index('<p class="page-verdict">'):]
@@ -93,7 +93,8 @@ async def test_a_playbook_page_runs_it_and_shows_its_history(app):
assert 'name="confirmed"' in main # the destructive flag's gate assert 'name="confirmed"' in main # the destructive flag's gate
assert 'name="var__prune_volumes"' in main and 'name="check"' in main assert 'name="var__prune_volumes"' in main and 'name="check"' in main
assert "Nightly prune" in main and "on group Docker Swarm · daily" in main assert "Nightly prune" in main and "on group Docker Swarm · daily" in main
assert 'href="/ansible/schedules?edit=s1#form"' in main assert 'href="/ansible/schedules/s1"' in main
assert 'href="/ansible/schedules/new?source=steward-builtin&amp;playbook=maintenance/prune.yml"' in main
assert 'href="/ansible/runs/r9"' in main and "End of the list." not in main assert 'href="/ansible/runs/r9"' in main and "End of the list." not in main
assert 'href="/ansible/?q=maintenance/prune.yml"' in main assert 'href="/ansible/?q=maintenance/prune.yml"' in main
assert "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only assert "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only
+6 -5
View File
@@ -102,14 +102,14 @@ async def test_the_runs_page_opens_on_its_verdict(app):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
failed = run_view(_run("ffff0000", AnsibleRunStatus.failed, 90, took_s=5), {"steward:target:t1": "target web-1"}, now) 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, calm = {"days": 30, "total": 33, "failed": 0, "running": 0, "latest_failed": None,
"next": {"name": "Nightly prune", "at": now + timedelta(hours=2, minutes=5)}} "next": {"id": "s1", "name": "Nightly prune", "at": now + timedelta(hours=2, minutes=5)}}
bad = {"days": 30, "total": 33, "failed": 2, "running": 1, "latest_failed": failed, "next": None} bad = {"days": 30, "total": 33, "failed": 2, "running": 1, "latest_failed": failed, "next": None}
async with app.test_request_context("/ansible/"): async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer" session["user_role"] = "viewer"
ok = await render_template("ansible/index.html", **_ctx([], calm)) ok = await render_template("ansible/index.html", **_ctx([], calm))
worse = await render_template("ansible/index.html", **_ctx([], bad, f={"q": "x", "status": "", "origin": ""})) worse = await render_template("ansible/index.html", **_ctx([], bad, f={"q": "x", "status": "", "origin": ""}))
assert '<span class="tone-ok">33 runs in the last 30 days, all succeeded</span>' in ok assert '<span class="tone-ok">33 runs in the last 30 days, all succeeded</span>' in ok
assert 'next: <a href="/ansible/schedules">Nightly prune</a> <time' in ok and ">in 2h" in ok assert 'next: <a href="/ansible/schedules/s1">Nightly prune</a> <time' in ok and ">in 2h" in ok
assert '<span class="tone-crit">2 of 33 runs failed</span> in the last 30 days' in worse assert '<span class="tone-crit">2 of 33 runs failed</span> in the last 30 days' in worse
assert 'latest <a href="/ansible/runs/ffff0000">update.yml on web-1</a>' in worse assert 'latest <a href="/ansible/runs/ffff0000">update.yml on web-1</a>' in worse
assert '<span class="tone-info">1 running now</span>' in worse assert '<span class="tone-info">1 running now</span>' in worse
@@ -126,12 +126,12 @@ def test_the_next_schedule_is_the_soonest_enabled_one():
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
def sched(name, ran_min_ago, every_s, enabled=True): def sched(name, ran_min_ago, every_s, enabled=True):
return SimpleNamespace(name=name, enabled=enabled, interval_seconds=every_s, return SimpleNamespace(id=f"id-{name}", 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) 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) daily, hourly = sched("daily", 60, 86400), sched("hourly", 30, 3600)
assert next_scheduled([daily, hourly], now)["name"] == "hourly" 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("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([daily, sched("new", None, 3600)], now) == {"id": "id-new", "name": "new", "at": None} # due now
assert next_scheduled([], now) is None assert next_scheduled([], now) is None
@@ -176,7 +176,8 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
assert '<span class="badge badge-warn">Interrupted</span>' in verdict and " in 10m" in verdict assert '<span class="badge badge-warn">Interrupted</span>' in verdict and " in 10m" in verdict
assert 'by <a href="/ansible/schedules">schedule nightly</a>' in verdict assert 'by <a href="/ansible/schedules">schedule nightly</a>' in verdict
assert "/cancel" not in page # an interrupted run has nothing to cancel assert "/cancel" not in page # an interrupted run has nothing to cancel
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "") # No run id as text: only in the links to the run and to run it again.
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "").replace("?from=dddd4444#", "")
# Each host's result in words, the failed host first. # Each host's result in words, the failed host first.
assert page.index(">zulu<") < page.index(">alpha<") assert page.index(">zulu<") < page.index(">alpha<")
assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page
+89
View File
@@ -0,0 +1,89 @@
"""Schedules read as sentences and each has its own page (#5332): the list's
row opens it, Run now and Pause stay in the row, and Delete is on the page
behind the confirm dialog."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from quart import render_template, session
from steward.models.ansible import AnsibleRunStatus
from tests.test_style_tokens import hex_colours
NOW = datetime.now(timezone.utc)
def _sched(sid="s1", name="Nightly prune", enabled=True, last_error=None, check=False):
return NS(id=sid, name=name, enabled=enabled, source_name="steward-builtin",
playbook_path="maintenance/docker_prune.yml", inventory_scope="steward:group:g1",
interval_seconds=86400, last_error=last_error, last_run_at=NOW - timedelta(hours=22),
last_run_id="r1", params={"check": True} if check else None)
def _last(status=AnsibleRunStatus.success):
return NS(id="r1", status=status, started_at=NOW - timedelta(hours=22))
async def test_the_schedules_list_reads_as_sentences(app):
ok, broken = _sched(), _sched("s2", "Weekly update", last_error="Playbook not found")
rows = [{"s": broken, "last_run": None, "scope_label": "group Docker Swarm", "next": None},
{"s": ok, "last_run": _last(), "scope_label": "group Docker Swarm",
"next": {"id": "s1", "name": "Nightly prune", "at": NOW + timedelta(hours=2)}}]
async with app.test_request_context("/ansible/schedules"):
session["user_role"] = "operator"
page = await render_template("ansible/schedules.html", rows=rows, failing=1, paused=0,
next=rows[1]["next"])
main = page[page.index("<main"):]
assert '<span class="tone-crit">1 of 2 failing</span>' in main
assert main.count('<tr class="row-link">') == 2 and 'href="/ansible/schedules/s1"' in main
assert "maintenance/docker_prune.yml</span> on group Docker Swarm" in main and "daily" in main
assert '<div class="cell-sub tone-crit" title="Playbook not found">Playbook not found' in main
assert main.count(">Run now</button>") == 2 and ">Pause</button>" in main
assert "/delete" not in main and 'href="/ansible/schedules/new"' in main # Delete is on the schedule's page
assert '<a href="/ansible/schedules" aria-current="page">Schedules</a>' in main
assert not hex_colours(main), hex_colours(main)
def _page_ctx(editing):
return {"editing": editing, "groups": [NS(id="g1", name="Docker Swarm")], "targets": [],
"source_data": [{"name": "steward-builtin", "playbooks": ["maintenance/docker_prune.yml"]}],
"sel_source": "steward-builtin", "sel_playbook": "maintenance/docker_prune.yml",
"sel_playbooks": ["maintenance/docker_prune.yml"], "interval_presets": [(86400, "Daily")],
"edit_variables": [], "edit_description": "", "edit_category": "", "edit_confirm": False,
"last_run": _last() if editing else None, "scope_label": "group Docker Swarm" if editing else None,
"next": {"id": "s1", "name": "Nightly prune", "at": NOW + timedelta(hours=2)} if editing else None}
async def test_a_schedule_page_says_what_it_does_and_deletes_behind_the_dialog(app):
async with app.test_request_context("/ansible/schedules/s1"):
session["user_role"] = "operator"
page = await render_template("ansible/schedule.html", **_page_ctx(_sched(check=True)))
main = page[page.index("<main"):]
assert 'href="/ansible/schedules"' in page # crumbs back to the list
verdict = main[main.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
assert 'href="/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"' in verdict
assert "on group Docker Swarm · daily" in verdict and "dry run" in verdict and "next <time" in verdict
assert 'last: <a href="/ansible/runs/r1"><span class="badge badge-ok">Succeeded</span></a>' in verdict
assert 'action="/ansible/schedules/s1"' in main and 'value="Nightly prune"' in main
assert 'name="check" checked' in main
assert 'data-confirm="Delete schedule Nightly prune?' in main and 'action="/ansible/schedules/s1/delete"' in main
assert ">Run now</button>" in main
assert 'style="' not in main[main.index('id="form"'):] # the form on classes
assert not hex_colours(main), hex_colours(main)
async def test_a_new_schedule_starts_on_the_chosen_playbook(app):
async with app.test_request_context("/ansible/schedules/new"):
session["user_role"] = "operator"
page = await render_template("ansible/schedule.html", **_page_ctx(None))
assert "<title>New schedule — Steward</title>" in page
assert 'action="/ansible/schedules"' in page and "/delete" not in page
assert '<option value="maintenance/docker_prune.yml" selected>' in page
async def test_a_viewer_reads_a_schedule_without_its_form(app):
async with app.test_request_context("/ansible/schedules/s1"):
session["user_role"] = "viewer"
page = await render_template("ansible/schedule.html", **_page_ctx(_sched()))
assert "on group Docker Swarm" in page and 'id="form"' not in page and "/delete" not in page
assert "Run now" not in page
+6 -4
View File
@@ -32,12 +32,14 @@ async def test_maintenance_offers_changes_only_to_those_who_can_make_them(app):
async def test_schedules_show_a_viewer_the_list_without_its_controls(app): async def test_schedules_show_a_viewer_the_list_without_its_controls(app):
s = NS(id="s1", name="nightly", enabled=True, source_name="local", playbook_path="prune.yml", s = NS(id="s1", name="nightly", enabled=True, source_name="local", playbook_path="prune.yml",
inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None) inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None, params=None)
rows = [{"s": s, "last_run": None, "next_run": NOW + timedelta(hours=2), "scope_label": "all inventory targets"}] nxt = {"id": "s1", "name": "nightly", "at": NOW + timedelta(hours=2)}
rows = [{"s": s, "last_run": None, "next": nxt, "scope_label": "all inventory targets"}]
async with app.test_request_context("/ansible/schedules"): async with app.test_request_context("/ansible/schedules"):
session["user_role"] = "viewer" session["user_role"] = "viewer"
page = await render_template("ansible/schedules.html", rows=rows, editing=None) page = await render_template("ansible/schedules.html", rows=rows, next=nxt, failing=0, paused=0)
assert "nightly" in page and "every 2h" in page and "7200s" not in page assert "nightly" in page and "every 2h" in page and "7200s" not in page
assert ">in 2h</time>" in page or ">in 1h 59m</time>" in page assert ">in 2h</time>" in page or ">in 1h 59m</time>" in page
for control in ("Run now", "Pause", "?edit=", "/delete", "New schedule"): assert 'class="row-target" href="/ansible/schedules/s1"' in page # the row opens the schedule
for control in ("Run now", "Pause", "/delete", "New schedule"):
assert control not in page, control assert control not in page, control