diff --git a/steward/ansible/origins.py b/steward/ansible/origins.py
index 02c701c..dc5e305 100644
--- a/steward/ansible/origins.py
+++ b/steward/ansible/origins.py
@@ -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",
diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py
index 3661d08..0eb2f60 100644
--- a/steward/ansible/routes.py
+++ b/steward/ansible/routes.py
@@ -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/")
+@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:
diff --git a/steward/static/steward.css b/steward/static/steward.css
index e2fa467..c8f6683 100644
--- a/steward/static/steward.css
+++ b/steward/static/steward.css
@@ -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; }
diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html
index db45b61..f240163 100644
--- a/steward/templates/ansible/index.html
+++ b/steward/templates/ansible/index.html
@@ -18,7 +18,7 @@
{%- if v.latest_failed %} — latest {{ v.latest_failed.title }} {{ v.latest_failed.run.started_at|ago }}{% endif %}
{%- else %}{{ v.total }} run{{ 's' if v.total != 1 }} in the last {{ v.days }} days, {{ 'all' if v.total > 1 else 'it' }} succeeded{% endif %}
{%- if v.running %} · {{ v.running }} running now{% endif %}
- {%- if v.next %} · next: {{ v.next.name }} {% if v.next.at %}{{ v.next.at|ago }}{% else %}due now{% endif %}{% endif -%}
+ {%- if v.next %} · next: {{ v.next.name }} {% if v.next.at %}{{ v.next.at|ago }}{% else %}due now{% endif %}{% endif -%}