From c2a79c8bb05ecc052d70e8f79b4c71adc47e1fab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 18:16:07 -0400 Subject: [PATCH] feat(ansible): runs named by what ran where, with ago, duration and one status badge (#5125) Runs read "update.yml on web-1" (run_title over the scope label, beside describe_scope) on the runs list, the run page's title and breadcrumb, and the run-started panel. The list shows started |ago (full time on hover), |duration (counting "so far" while a run is going) and a dry-run tag. Run statuses join STATES, so the list and the run page use status_badge: queued dim, running info (blue, so work in progress doesn't read as a problem), success ok "Succeeded", failed crit, interrupted warn, cancelled dim. run_started.html leaves its hard-coded colours for tokens; the run page's facts move to the .kv list and its failures to var(--red). Template hex colours: 54 -> 47. Also fixes #5124's sidebar count, which CI run 8389 failed on: the stylesheet guard allows no weight above 500, and .nav-count asked for 600. Co-Authored-By: Claude Opus 5.5 --- steward/ansible/inventory_gen.py | 9 +++ steward/ansible/routes.py | 29 +++++-- steward/core/formatting.py | 8 ++ steward/static/steward.css | 12 ++- steward/templates/ansible/index.html | 40 ++++------ steward/templates/ansible/run_detail.html | 65 ++++++---------- steward/templates/ansible/run_started.html | 18 ++--- tests/core/test_ansible_runs_page.py | 90 ++++++++++++++++++++++ tests/test_style_tokens.py | 2 +- 9 files changed, 194 insertions(+), 79 deletions(-) create mode 100644 tests/core/test_ansible_runs_page.py diff --git a/steward/ansible/inventory_gen.py b/steward/ansible/inventory_gen.py index 15c50e9..2dcce90 100644 --- a/steward/ansible/inventory_gen.py +++ b/steward/ansible/inventory_gen.py @@ -127,6 +127,15 @@ def describe_scope(scope: str, targets: dict[str, str], groups: dict[str, str]) return scope +def run_title(playbook: str, scope_label: str | None) -> str: + """A run named by what it ran where: "update.yml on web-1", + "site.yml on group Docker Swarm", "ping.yml on all inventory targets".""" + where = scope_label or "" + if where.startswith("target "): + where = where[len("target "):] + return f"{playbook} on {where}" if where else playbook + + async def scope_labels(db, scopes) -> dict[str, str]: """{scope: describe_scope(scope)} for the given scopes, in two queries.""" from sqlalchemy import select diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index b225e2b..dd291c7 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -9,7 +9,7 @@ from quart import ( from sqlalchemy import select from steward.ansible import executor, guards, sources as src_module -from steward.ansible.inventory_gen import scope_labels +from steward.ansible.inventory_gen import run_title, scope_labels from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.models.ansible import AnsibleRun, AnsibleRunStatus @@ -41,7 +41,24 @@ async def index(): select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50) ) runs = result.scalars().all() - return await render_template("ansible/index.html", runs=runs) + labels = await scope_labels(db, [r.inventory_scope for r in runs]) + now = datetime.now(timezone.utc) + return await render_template("ansible/index.html", runs=[run_view(r, labels, now) for r in runs]) + + +def run_duration(run: AnsibleRun, now: datetime) -> float | None: + """Seconds a run took, or has taken so far while it is still going.""" + if run.started_at is None: + return None + return ((run.finished_at or now) - run.started_at).total_seconds() + + +def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict: + """A run as the pages name it: its title, scope label and duration.""" + label = labels.get(run.inventory_scope, run.inventory_scope) + return {"run": run, "title": run_title(run.playbook_path, label), "scope_label": label, + "duration_s": run_duration(run, now), + "check": bool((run.params or {}).get("check"))} @ansible_bp.get("/browse") @@ -285,10 +302,12 @@ async def create_run(): "check": bool((params_or_none or {}).get("check")), "vars": sorted(_run_vars(params_or_none, secret_vars))}) + async with current_app.db_sessionmaker() as db: + labels = await scope_labels(db, [run.inventory_scope]) return await render_template( "ansible/run_started.html", run=run, - source=source, + title=run_title(run.playbook_path, labels.get(run.inventory_scope, run.inventory_scope)), ) @@ -340,10 +359,10 @@ async def run_detail(run_id: str): res = await db.execute( select(User.username).where(User.id == run.triggered_by)) triggered_label = res.scalar_one_or_none() or "(deleted user)" - scope_label = (await scope_labels(db, [run.inventory_scope])).get(run.inventory_scope) + labels = await scope_labels(db, [run.inventory_scope]) return await render_template( "ansible/run_detail.html", run=run, triggered_label=triggered_label, - scope_label=scope_label) + row=run_view(run, labels, datetime.now(timezone.utc))) @ansible_bp.post("/runs//cancel") diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 867bac2..0190aef 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -116,6 +116,14 @@ STATES: dict[str, tuple[str, str]] = { "inactive": ("dim", "Inactive"), "paused": ("dim", "Paused"), "unknown": ("dim", "Unknown"), + # An Ansible run (AnsibleRunStatus): working is blue, not amber, so a run + # in progress doesn't read as a problem. + "queued": ("dim", "Queued"), + "running": ("info", "Running"), + "success": ("ok", "Succeeded"), + "failed": ("crit", "Failed"), + "interrupted": ("warn", "Interrupted"), + "cancelled": ("dim", "Cancelled"), } diff --git a/steward/static/steward.css b/steward/static/steward.css index ad37bb1..5af55fe 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -89,7 +89,7 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b /* How many alerts need attention, beside the Alerts link. */ .nav-count { margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm); - background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 600; + background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500; font-variant-numeric: tabular-nums; } .side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; } @@ -274,6 +274,16 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat /* A label/value list:
…
…
. */ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; } .kv dt { color: var(--text-muted); } +/* An Ansible run just started from the run form (ansible/run_started.html). */ +.run-started-head { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; font-size: 0.9rem; } +.run-started-link { margin-left: auto; font-size: 0.82rem; } +.run-output { + margin: 0 0 0.5rem; padding: 0.75rem; background: var(--bg); border-radius: var(--fs-radius-sm); + font-family: var(--fs-font-mono); font-size: 0.78rem; color: var(--text-muted); + max-height: 400px; overflow-y: auto; white-space: pre-wrap; +} +/* Monospaced data: ids, limits, tags, paths. */ +.mono { font-family: var(--fs-font-mono); font-size: 0.85em; } /* The host page's health header (_host_vitals.html). */ .host-health-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.75rem; margin-bottom: 0.75rem; } .host-health-summary { font-size: 0.92rem; } diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index ad747a3..90a06c5 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -1,43 +1,37 @@ {% extends "base.html" %} +{% from "_macros.html" import page_header, status_badge, empty_state %} {% block title %}Ansible Runs — Steward{% endblock %} {% block content %} -
-

Ansible Runs

-
- Inventory - Schedules - Browse Playbooks -
-
+{% call page_header("Ansible Runs", "Each run by what it ran where, newest first.") %} + Inventory + Schedules + Browse Playbooks +{% endcall %} {% if not runs %} -
-

No runs yet. Browse playbooks to begin.

-
+
{{ empty_state("No runs yet.", "Browse playbooks", "/ansible/browse") }}
{% else %}
- - + - + - {% for run in runs %} - {% set status_color = {"queued": "var(--text-dim)", "running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)", "cancelled": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %} + {% for row in runs %} + {% set run = row.run %} - - - - + + + {% endfor %} diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index 8e1e93b..af73437 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -1,48 +1,33 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs %} -{% block title %}Run {{ run.id[:8] }} — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Run " ~ run.id[:8], "")]) }}{% endblock %} +{% from "_macros.html" import crumbs, page_header, status_badge %} +{% block title %}{{ row.title }} — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), (row.title, "")]) }}{% endblock %} {% block content %} -
-

Run Detail

-
- {% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %} -
{{ csrf_field() }} - - - {% endif %} - Download log -
-
+{% call page_header(row.title, "Run " ~ run.id[:8] ~ " from " ~ run.source_name) %} + {% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %} + {{ csrf_field() }} + + + {% endif %} + Download log +{% endcall %} -{% set status_color = {"queued": "var(--text-dim)", "running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)", "cancelled": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
-
- ID{{ run.id }} - Playbook{{ run.playbook_path }} - Scope{{ scope_label or run.inventory_scope }} - Source{{ run.source_name }} - Triggered by{{ triggered_label }} - Status{{ run.status.value.upper() }} - Started{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} - {% if run.finished_at %} - Finished{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} - {% endif %} +
+
Status
{{ status_badge(run.status) }}{% if row.check %} dry run{% endif %}
+
Started
{{ run.started_at|ago }}
+
Duration
{{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}
+
Playbook
{{ run.playbook_path }}
+
Scope
{{ row.scope_label }}
+
Source
{{ run.source_name }}
+
Triggered by
{{ triggered_label }}
{% if run.params %} - {% if run.params.limit %} - Limit{{ run.params.limit }} + {% if run.params.limit %}
Limit
{{ run.params.limit }}
{% endif %} + {% if run.params.tags %}
Tags
{{ run.params.tags }}
{% endif %} + {% if run.params.extra_vars %}
Extra vars
{{ run.params.extra_vars | join(", ") }}
{% endif %} {% endif %} - {% if run.params.tags %} - Tags{{ run.params.tags }} - {% endif %} - {% if run.params.check %} - Modedry-run (--check --diff) - {% endif %} - {% if run.params.extra_vars %} - Extra vars{{ run.params.extra_vars | join(", ") }} - {% endif %} - {% endif %} -
+
ID
{{ run.id }}
+
{% if run.results and run.results.hosts %} @@ -70,7 +55,7 @@
PlaybookSourceRun Status StartedDuration
{{ run.playbook_path }}{{ run.source_name }} - {{ run.status.value.upper() }} - {{ run.started_at.strftime("%Y-%m-%d %H:%M") }} - View + {{ row.title }} + {% if row.check %}dry run{% endif %} +
{{ run.source_name }}
{{ status_badge(run.status) }}{{ run.started_at|ago }}{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}
{% if run.results.failures %}
Failures
-
{{ run.results.failures | join("\n") }}
+
{{ run.results.failures | join("\n") }}
{% endif %}
{% endif %} diff --git a/steward/templates/ansible/run_started.html b/steward/templates/ansible/run_started.html index 3d958ac..e4db154 100644 --- a/steward/templates/ansible/run_started.html +++ b/steward/templates/ansible/run_started.html @@ -1,13 +1,13 @@ -
-
- RUNNING - {{ run.playbook_path }} - Full view → +{# The run form's answer: the run just started, streaming its output in place. #} +{% from "_macros.html" import status_badge %} +
+
+ {{ status_badge('running') }} + {{ title }} + Full view →
-
-    
-
+

+    
Streaming output…