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:
|
||||
|
||||
Reference in New Issue
Block a user