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 -%}