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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}&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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -83,7 +83,7 @@ async def test_a_playbook_page_runs_it_and_shows_its_history(app):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[
|
||||
{"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"):]
|
||||
assert 'href="/ansible/playbooks"' in page # crumbs back to the list
|
||||
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="var__prune_volumes"' in main and 'name="check"' 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&playbook=maintenance/prune.yml"' 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 "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only
|
||||
|
||||
@@ -102,14 +102,14 @@ async def test_the_runs_page_opens_on_its_verdict(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
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,
|
||||
"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}
|
||||
async with app.test_request_context("/ansible/"):
|
||||
session["user_role"] = "viewer"
|
||||
ok = await render_template("ansible/index.html", **_ctx([], calm))
|
||||
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 '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 '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
|
||||
@@ -126,12 +126,12 @@ def test_the_next_schedule_is_the_soonest_enabled_one():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
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)
|
||||
daily, hourly = sched("daily", 60, 86400), sched("hourly", 30, 3600)
|
||||
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("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
|
||||
|
||||
|
||||
@@ -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 'by <a href="/ansible/schedules">schedule nightly</a>' in verdict
|
||||
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.
|
||||
assert page.index(">zulu<") < page.index(">alpha<")
|
||||
assert '<span class="tone-crit">1 failed</span> · 1 ok' in page and ">3 ok<" in page
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
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)
|
||||
rows = [{"s": s, "last_run": None, "next_run": NOW + timedelta(hours=2), "scope_label": "all inventory targets"}]
|
||||
inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None, params=None)
|
||||
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"):
|
||||
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 ">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
|
||||
|
||||
Reference in New Issue
Block a user