feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330)
- section_tabs macro; Runs · Playbooks · Schedules · Inventory on every Ansible page, replacing each page's own set of header buttons. - Runs: a verdict line (last 30 days, failures first with the latest named, running now, the schedule due next); under each status what the run did across its hosts (ansible/recap.py); a filter bar (playbook, status, who started it) that swaps the table and carries into the scroll paging. - Run page: status, duration, start and who started it in one line; each host's result in words, worst first; the playbook it ran links to it; the log and failures on classes rather than inline colours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""A run's play recap in words (#5329, #5330): what the runs list says under a
|
||||
run's status and what the run page says beside each host.
|
||||
|
||||
A run's `results` is {hosts: {name: {ok, changed, unreachable, failed,
|
||||
skipped}}, failures: [...]}, parsed from the play recap. Model-free, so it is
|
||||
unit-tested without a database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
NAMES_SHOWN = 2
|
||||
|
||||
|
||||
def _n(counts: dict | None, key: str) -> int:
|
||||
return int((counts or {}).get(key) or 0)
|
||||
|
||||
|
||||
def _names(names: list[str]) -> str:
|
||||
""""a", "a and b", "a, b and 3 more"."""
|
||||
if len(names) <= NAMES_SHOWN:
|
||||
return " and ".join(names)
|
||||
return f"{', '.join(names[:NAMES_SHOWN])} and {len(names) - NAMES_SHOWN} more"
|
||||
|
||||
|
||||
def run_recap(results: dict | None, check: bool = False) -> list[tuple[str, str | None]]:
|
||||
"""A run's outcome across its hosts as (text, tone) parts, the worst
|
||||
first: "failed on web-1" (crit), "db-2 unreachable" (crit), "3 hosts",
|
||||
"2 changed" ("would change" for a dry run, which changed nothing).
|
||||
Empty when the run left no recap (it is still going, or died first)."""
|
||||
hosts = (results or {}).get("hosts") or {}
|
||||
if not hosts:
|
||||
return []
|
||||
failed = sorted(h for h, c in hosts.items() if _n(c, "failed"))
|
||||
unreachable = sorted(h for h, c in hosts.items() if _n(c, "unreachable") and h not in failed)
|
||||
changed = sum(1 for c in hosts.values() if _n(c, "changed"))
|
||||
parts: list[tuple[str, str | None]] = []
|
||||
if failed:
|
||||
parts.append((f"failed on {_names(failed)}", "crit"))
|
||||
if unreachable:
|
||||
parts.append((f"{_names(unreachable)} unreachable", "crit"))
|
||||
parts.append((f"{len(hosts)} host{'s' if len(hosts) != 1 else ''}", None))
|
||||
if changed:
|
||||
parts.append((f"{changed} would change" if check else f"{changed} changed", None))
|
||||
elif not failed and not unreachable:
|
||||
parts.append(("nothing to change" if check else "nothing changed", "muted"))
|
||||
return parts
|
||||
|
||||
|
||||
def host_recap(counts: dict | None, check: bool = False) -> list[tuple[str, str | None]]:
|
||||
"""One host's task counts as (text, tone) parts, zeros left out and the
|
||||
worst first: "unreachable", "1 failed", "3 ok", "1 changed", "2 skipped"."""
|
||||
parts: list[tuple[str, str | None]] = []
|
||||
if _n(counts, "unreachable"):
|
||||
parts.append(("unreachable", "crit"))
|
||||
if _n(counts, "failed"):
|
||||
parts.append((f"{_n(counts, 'failed')} failed", "crit"))
|
||||
if _n(counts, "ok"):
|
||||
parts.append((f"{_n(counts, 'ok')} ok", None))
|
||||
if _n(counts, "changed"):
|
||||
word = "would change" if check else "changed"
|
||||
parts.append((f"{_n(counts, 'changed')} {word}", None))
|
||||
if _n(counts, "skipped"):
|
||||
parts.append((f"{_n(counts, 'skipped')} skipped", "muted"))
|
||||
return parts
|
||||
+104
-13
@@ -6,11 +6,12 @@ from quart import (
|
||||
Blueprint, Response, current_app, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from steward.ansible import executor, guards, sources as src_module
|
||||
from steward.ansible.inventory_gen import run_title, scope_labels
|
||||
from steward.ansible.origins import run_origins
|
||||
from steward.ansible.recap import host_recap, run_recap
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core.audit import audit_request as _audit
|
||||
from steward.core.paging import keyset, next_page
|
||||
@@ -35,24 +36,112 @@ def _get_sources() -> list[dict]:
|
||||
return src_module.get_sources(ansible_cfg)
|
||||
|
||||
|
||||
# The runs list's filters (#5329): what each status choice and origin choice
|
||||
# covers. A run started on the web pages has no origin and a user.
|
||||
RUN_STATUSES = {
|
||||
"failed": ("Failed", (AnsibleRunStatus.failed, AnsibleRunStatus.interrupted)),
|
||||
"success": ("Succeeded", (AnsibleRunStatus.success,)),
|
||||
"running": ("Running", (AnsibleRunStatus.running, AnsibleRunStatus.queued)),
|
||||
"cancelled": ("Cancelled", (AnsibleRunStatus.cancelled,)),
|
||||
}
|
||||
RUN_ORIGINS = {"schedule": "A schedule", "alert": "An alert rule", "mcp": "An AI agent", "manual": "A person"}
|
||||
VERDICT_DAYS = 30
|
||||
|
||||
|
||||
def run_filters(args) -> dict:
|
||||
"""The runs list's filters from the query string, unknown values dropped."""
|
||||
status = args.get("status", "")
|
||||
origin = args.get("origin", "")
|
||||
return {"q": (args.get("q") or "").strip()[:200],
|
||||
"status": status if status in RUN_STATUSES else "",
|
||||
"origin": origin if origin in RUN_ORIGINS else ""}
|
||||
|
||||
|
||||
def filter_runs(stmt, f: dict):
|
||||
"""`stmt` narrowed to the runs the filters choose."""
|
||||
if f["q"]:
|
||||
stmt = stmt.where(func.lower(AnsibleRun.playbook_path).contains(f["q"].lower(), autoescape=True))
|
||||
if f["status"]:
|
||||
stmt = stmt.where(AnsibleRun.status.in_(RUN_STATUSES[f["status"]][1]))
|
||||
if f["origin"] == "manual":
|
||||
stmt = stmt.where(AnsibleRun.origin.is_(None), AnsibleRun.triggered_by.is_not(None))
|
||||
elif f["origin"]:
|
||||
stmt = stmt.where(AnsibleRun.origin == f["origin"])
|
||||
return stmt
|
||||
|
||||
|
||||
def next_scheduled(schedules, now: datetime) -> dict | None:
|
||||
"""The enabled schedule due soonest: {"name", "at"}, `at` None when it is
|
||||
due now (it has never run, or its turn has passed)."""
|
||||
best = None
|
||||
for s in schedules:
|
||||
if not s.enabled:
|
||||
continue
|
||||
at = s.last_run_at + timedelta(seconds=s.interval_seconds) if s.last_run_at else None
|
||||
if at is not None and at <= now:
|
||||
at = None
|
||||
key = at or now
|
||||
if best is None or key < best[0]:
|
||||
best = (key, {"name": s.name, "at": at})
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
async def runs_verdict(db, now: datetime) -> dict:
|
||||
"""What the Runs page opens on: runs in the last VERDICT_DAYS days, how
|
||||
many failed and the newest of those, how many are going now, and the
|
||||
schedule due next."""
|
||||
since = now - timedelta(days=VERDICT_DAYS)
|
||||
bad = RUN_STATUSES["failed"][1]
|
||||
going = RUN_STATUSES["running"][1]
|
||||
total, failed, running = (await db.execute(select(
|
||||
func.count(),
|
||||
func.count().filter(AnsibleRun.status.in_(bad)),
|
||||
func.count().filter(AnsibleRun.status.in_(going)),
|
||||
).select_from(AnsibleRun).where(AnsibleRun.started_at >= since))).one()
|
||||
latest_failed = None
|
||||
if failed:
|
||||
run = (await db.execute(select(AnsibleRun).where(
|
||||
AnsibleRun.started_at >= since, AnsibleRun.status.in_(bad))
|
||||
.order_by(AnsibleRun.started_at.desc()).limit(1))).scalar_one()
|
||||
labels = await scope_labels(db, [run.inventory_scope])
|
||||
latest_failed = run_view(run, labels, now)
|
||||
schedules = (await db.execute(select(AnsibleSchedule))).scalars().all()
|
||||
return {"days": VERDICT_DAYS, "total": total, "failed": failed, "running": running,
|
||||
"latest_failed": latest_failed, "next": next_scheduled(schedules, now)}
|
||||
|
||||
|
||||
@ansible_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
"""Every run, newest first, a page at a time as the reader scrolls;
|
||||
?before=<cursor> is the next page's rows alone (core.paging)."""
|
||||
"""Every run, newest first, a page at a time as the reader scrolls, under
|
||||
a verdict line and a filter bar (#5329). ?before=<cursor> is the next
|
||||
page's rows alone (core.paging); an HTMX request from the filter bar is
|
||||
the table alone."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
f = run_filters(request.args)
|
||||
before = request.args.get("before")
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
stmt = keyset(select(AnsibleRun), AnsibleRun.started_at, AnsibleRun.id, before)
|
||||
stmt = keyset(filter_runs(select(AnsibleRun), f), AnsibleRun.started_at, AnsibleRun.id, before)
|
||||
runs, after = next_page((await db.execute(stmt)).scalars().all(),
|
||||
lambda r: (r.started_at, r.id))
|
||||
labels = await scope_labels(db, [r.inventory_scope for r in runs])
|
||||
origins = await run_origins(db, runs)
|
||||
now = datetime.now(timezone.utc)
|
||||
verdict = None if before or request.headers.get("HX-Request") else await runs_verdict(db, now)
|
||||
kept = {k: v for k, v in f.items() if v}
|
||||
ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
|
||||
"after": after}
|
||||
"more_url": "/ansible/?" + urlencode({**kept, "before": after}) if after else None,
|
||||
"f": f, "filtered": bool(kept), "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
|
||||
"verdict": verdict}
|
||||
if before:
|
||||
return await render_template("ansible/_runs_page.html", **ctx)
|
||||
return await render_template("ansible/index.html", **ctx)
|
||||
if request.headers.get("HX-Request"):
|
||||
resp = await current_app.make_response(await render_template("ansible/_runs_table.html", **ctx))
|
||||
else:
|
||||
resp = await current_app.make_response(await render_template("ansible/index.html", **ctx))
|
||||
resp.vary.add("HX-Request")
|
||||
return resp
|
||||
|
||||
|
||||
def run_duration(run: AnsibleRun, now: datetime) -> float | None:
|
||||
@@ -63,11 +152,13 @@ def run_duration(run: AnsibleRun, now: datetime) -> float | None:
|
||||
|
||||
|
||||
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."""
|
||||
"""A run as the pages name it: its title, scope label, duration and what
|
||||
it did across its hosts in words (#5329)."""
|
||||
label = labels.get(run.inventory_scope, run.inventory_scope)
|
||||
check = bool((run.params or {}).get("check"))
|
||||
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"))}
|
||||
"duration_s": run_duration(run, now), "check": check,
|
||||
"recap": run_recap(run.results, check)}
|
||||
|
||||
|
||||
@ansible_bp.get("/browse")
|
||||
@@ -364,9 +455,9 @@ async def run_detail(run_id: str):
|
||||
return "Not found", 404
|
||||
origin = (await run_origins(db, [run]))[run.id]
|
||||
labels = await scope_labels(db, [run.inventory_scope])
|
||||
return await render_template(
|
||||
"ansible/run_detail.html", run=run, origin=origin, hosts=hosts_worst_first(run.results),
|
||||
row=run_view(run, labels, datetime.now(timezone.utc)))
|
||||
row = run_view(run, labels, datetime.now(timezone.utc))
|
||||
hosts = [(name, host_recap(c, row["check"])) for name, c in hosts_worst_first(run.results)]
|
||||
return await render_template("ansible/run_detail.html", run=run, origin=origin, hosts=hosts, row=row)
|
||||
|
||||
|
||||
def hosts_worst_first(results: dict | None) -> list[tuple[str, dict]]:
|
||||
|
||||
@@ -352,6 +352,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
|
||||
.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
|
||||
.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; }
|
||||
/* A section's pages as tabs (section_tabs in _macros.html, #5328). It
|
||||
scrolls inside itself on a phone rather than widening the page. */
|
||||
.section-tabs { display: flex; gap: 0.25rem; margin: 0 0 1.25rem; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.section-tabs a { padding: 0.5rem 0.9rem; margin-bottom: -1px; border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted); font-size: 0.9rem; white-space: nowrap; }
|
||||
.section-tabs a:hover { color: var(--text); }
|
||||
.section-tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||
/* A label/value list: <dl class="kv"><dt>…</dt><dd>…</dd></dl>. */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; }
|
||||
.kv dt { color: var(--text-muted); }
|
||||
@@ -363,6 +370,11 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
|
||||
font-family: var(--fs-font-mono); font-size: 0.78rem; color: var(--text-muted);
|
||||
max-height: 400px; overflow-y: auto; white-space: pre-wrap;
|
||||
}
|
||||
/* A run's log on its page (ansible/run_detail.html), and what failed. */
|
||||
.run-log { margin: 0; padding: 1rem; background: var(--bg); font-family: var(--fs-font-mono); font-size: 0.78rem;
|
||||
color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; }
|
||||
.run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; }
|
||||
.run-log-status { margin: 0; padding: 0.5rem 1rem; }
|
||||
/* 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). */
|
||||
|
||||
@@ -73,6 +73,17 @@
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{# A section's own pages as tabs under the crumbs (#5328): items are
|
||||
(key, label, href); the one whose key is `active` is the current page.
|
||||
Links, not a tablist: each tab is its own page. #}
|
||||
{% macro section_tabs(items, active, label) -%}
|
||||
<nav class="section-tabs" aria-label="{{ label }}">
|
||||
{%- for key, text, href in items %}
|
||||
<a href="{{ href }}"{% if key == active %} aria-current="page"{% endif %}>{{ text }}</a>
|
||||
{%- endfor %}
|
||||
</nav>
|
||||
{%- endmacro %}
|
||||
|
||||
{# A button that shows only an icon. The label is required: it is the button's
|
||||
accessible name and its tooltip, and rendering one without it raises. #}
|
||||
{% macro icon_button(icon, label, href=none, cls="btn-ghost") -%}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the
|
||||
next page as the reader nears it (core.paging, pref 172). The whole row
|
||||
opens the run (.row-link); its title is the one real link. #}
|
||||
opens the run (.row-link); its title is the one real link. Under the status,
|
||||
what the run did across its hosts (ansible.recap.run_recap). #}
|
||||
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
|
||||
{% for row in runs %}
|
||||
{% set run = row.run %}
|
||||
<tr class="row-link">
|
||||
<td>
|
||||
<a class="row-target" href="/ansible/runs/{{ run.id }}">{{ row.title }}</a>
|
||||
{% if row.check %}<span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
|
||||
<div class="field-hint">{{ run.source_name }}</div>
|
||||
<td class="cell-name">
|
||||
<a class="row-target" href="/ansible/runs/{{ run.id }}" title="{{ row.title }}">{{ row.title }}</a>
|
||||
<div class="cell-sub">{{ run.source_name }}{% if row.check %} · <span class="tone-info" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}</div>
|
||||
</td>
|
||||
<td>{{ status_badge(run.status) }}</td>
|
||||
<td style="font-size:0.85rem;">{% if row.origin %}{% if row.origin.url %}<a href="{{ row.origin.url }}">{{ row.origin.text }}</a>{% else %}{{ row.origin.text }}{% endif %}{% endif %}</td>
|
||||
<td title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
|
||||
<td>{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
|
||||
<td>
|
||||
{{ status_badge(run.status) }}
|
||||
{%- if row.recap %}
|
||||
<div class="cell-sub">{% for text, tone in row.recap %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}</div>
|
||||
{%- endif %}
|
||||
</td>
|
||||
<td>{% if row.origin %}{{ row.origin.text }}{% endif %}</td>
|
||||
<td class="nowrap" title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
|
||||
<td class="num nowrap">{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if after %}
|
||||
{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=5) }}
|
||||
{% if more_url %}
|
||||
{{ scroll_more(more_url, cols=5) }}
|
||||
{% else %}
|
||||
{{ scroll_end(cols=5) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{# ansible/_runs_table.html — the runs table under the filter bar; the bar's
|
||||
HTMX requests swap this alone. #}
|
||||
{% from "_macros.html" import empty_state %}
|
||||
{% if not runs %}
|
||||
<div class="card">
|
||||
{%- if filtered %}{{ empty_state("No runs match these filters.", "Show every run", "/ansible/") }}
|
||||
{%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/browse") }}{% endif -%}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Result</th>
|
||||
<th>Started by</th>
|
||||
<th>Started</th>
|
||||
<th class="num">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "ansible/_runs_page.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{# ansible/_tabs.html — the Ansible section's pages as one strip of tabs (#5328),
|
||||
on every Ansible page; `active` names the page or the one it sits under. #}
|
||||
{% from "_macros.html" import section_tabs %}
|
||||
{% macro ansible_tabs(active) -%}
|
||||
{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/browse"),
|
||||
("schedules", "Schedules", "/ansible/schedules"),
|
||||
("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }}
|
||||
{%- endmacro %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Browse Playbooks — Steward{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
@@ -9,14 +10,13 @@
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
{% if session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/new" class="btn btn-sm">New playbook</a>
|
||||
{% endif %}
|
||||
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Inventory</a>
|
||||
<a href="/ansible/schedules" class="btn btn-ghost btn-sm">Schedules</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
{# ansible/index.html — the Runs tab (#5329): a verdict line (how the last
|
||||
30 days went, failures first, what runs next), a filter bar, then every run
|
||||
newest first as the reader scrolls (ansible/_runs_table.html). #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import page_header, status_badge, empty_state %}
|
||||
{% from "_macros.html" import page_header %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% block title %}Ansible Runs — Steward{% endblock %}
|
||||
{% block content %}
|
||||
{% call page_header("Ansible Runs", "Each run by what it ran where, newest first.") %}
|
||||
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
|
||||
<a href="/ansible/schedules" class="btn btn-ghost">Schedules</a>
|
||||
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
|
||||
{{ ansible_tabs("runs") }}
|
||||
{% call page_header("Ansible Runs") %}
|
||||
<a href="/ansible/browse" class="btn">Run a playbook</a>
|
||||
{% endcall %}
|
||||
{% if not runs %}
|
||||
<div class="card">{{ empty_state("No runs yet.", "Browse playbooks", "/ansible/browse") }}</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Status</th>
|
||||
<th>Started by</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "ansible/_runs_page.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set v = verdict %}
|
||||
<p class="page-verdict">
|
||||
{%- if not v.total %}<span class="tone-muted">No runs in the last {{ v.days }} days</span>
|
||||
{%- elif v.failed %}<span class="tone-crit">{{ v.failed }} of {{ v.total }} run{{ 's' if v.total != 1 }} failed</span> in the last {{ v.days }} days
|
||||
{%- 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 -%}
|
||||
</p>
|
||||
|
||||
<form class="filter-bar" method="get" action="/ansible/" role="search"
|
||||
hx-target="#runs-table" hx-push-url="true" hx-include="closest form">
|
||||
<input type="search" name="q" value="{{ f.q }}" class="input" placeholder="Search playbooks"
|
||||
aria-label="Search runs by playbook" autocomplete="off"
|
||||
hx-get="/ansible/" hx-trigger="input changed delay:250ms, search">
|
||||
<select name="status" aria-label="Status" hx-get="/ansible/" hx-trigger="change">
|
||||
<option value="">Any status</option>
|
||||
{% for key, (label, _) in statuses.items() %}<option value="{{ key }}"{% if f.status == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="origin" aria-label="Started by" hx-get="/ansible/" hx-trigger="change">
|
||||
<option value="">Started by anyone</option>
|
||||
{% for key, label in origins.items() %}<option value="{{ key }}"{% if f.origin == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
|
||||
</form>
|
||||
<div id="runs-table">{% include "ansible/_runs_table.html" %}</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Group: {{ group.name }} — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "/ansible/inventory/groups"), (group.name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("inventory") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Inventory Groups — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Groups", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("inventory") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Target: {{ target.name }} — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "/ansible/inventory/targets"), (target.name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("inventory") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}Inventory Targets — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Inventory", "/ansible/inventory/targets"), ("Targets", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("inventory") }}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}{{ "Edit" if editing else "New" }} Playbook — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ ansible_tabs("playbooks") }}
|
||||
<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;">{{ "Edit playbook" if editing else "New playbook" }}</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
{# ansible/run_detail.html — one run (#5330): a verdict line (how it ended,
|
||||
how long, when, who started it, what it did across its hosts), what it ran
|
||||
with, each host's result in words, worst first, then its output. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header, status_badge %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% block title %}{{ row.title }} — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), (row.title, "")]) }}{% endblock %}
|
||||
{% macro words(parts) -%}
|
||||
{% for text, tone in parts %}{% if not loop.first %} · {% endif %}{% if tone %}<span class="tone-{{ tone }}">{{ text }}</span>{% else %}{{ text }}{% endif %}{% endfor %}
|
||||
{%- endmacro %}
|
||||
{% block content %}
|
||||
{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %}
|
||||
{{ ansible_tabs("runs") }}
|
||||
{% call page_header(row.title) %}
|
||||
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run? Ansible stops where it is; tasks already applied stay applied." data-confirm-dismiss="Keep running">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
@@ -12,15 +20,19 @@
|
||||
<a href="/ansible/runs/{{ run.id }}/log" class="btn btn-sm btn-ghost">Download log</a>
|
||||
{% endcall %}
|
||||
|
||||
<p class="page-verdict">
|
||||
{{- status_badge(run.status) }}
|
||||
{%- if row.check %} <span class="tone-info" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
|
||||
{%- if row.duration_s is not none %} {% if run.finished_at %}in{% else %}for{% endif %} {{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}{% endif %}
|
||||
· started {{ run.started_at|ago }}
|
||||
· by {% if origin.url %}<a href="{{ origin.url }}">{{ origin.text }}</a>{% else %}{{ origin.text }}{% endif %}
|
||||
{%- if row.recap %} · {{ words(row.recap) }}{% endif -%}
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Status</dt><dd>{{ status_badge(run.status) }}{% if row.check %} <span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}</dd>
|
||||
<dt>Started</dt><dd title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</dd>
|
||||
<dt>Duration</dt><dd>{{ row.duration_s|duration }}{% if not run.finished_at %} so far{% endif %}</dd>
|
||||
<dt>Playbook</dt><dd>{{ run.playbook_path }}</dd>
|
||||
<dt>Scope</dt><dd title="{{ run.inventory_scope }}">{{ row.scope_label }}</dd>
|
||||
<dt>Source</dt><dd>{{ run.source_name }}</dd>
|
||||
<dt>Started by</dt><dd>{% if origin.url %}<a href="{{ origin.url }}">{{ origin.text }}</a>{% else %}{{ origin.text }}{% endif %}</dd>
|
||||
<dt>Playbook</dt><dd><a href="/ansible/browse/{{ run.source_name }}/{{ run.playbook_path }}">{{ run.playbook_path }}</a> <span class="tone-dim">from {{ run.source_name }}</span></dd>
|
||||
<dt>On</dt><dd>{{ row.scope_label }}</dd>
|
||||
{% if run.params %}
|
||||
{% if run.params.limit %}<dt>Limit</dt><dd class="mono">{{ run.params.limit }}</dd>{% endif %}
|
||||
{% if run.params.tags %}<dt>Tags</dt><dd class="mono">{{ run.params.tags }}</dd>{% endif %}
|
||||
@@ -30,45 +42,30 @@
|
||||
</div>
|
||||
|
||||
{% if hosts %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Host summary</div>
|
||||
<section class="card-flush" aria-labelledby="run-hosts">
|
||||
<h2 id="run-hosts" class="table-caption">Hosts <span class="tone-muted">worst first</span></h2>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>Host</th>
|
||||
<th style="text-align:center;">ok</th><th style="text-align:center;">changed</th>
|
||||
<th style="text-align:center;">unreachable</th><th style="text-align:center;">failed</th>
|
||||
<th style="text-align:center;">skipped</th>
|
||||
</tr></thead>
|
||||
<thead><tr><th>Host</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{% for host, c in hosts %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;">{{ host }}</td>
|
||||
<td style="text-align:center;">{{ c.ok }}</td>
|
||||
<td style="text-align:center;color:{% if c.changed %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ c.changed }}</td>
|
||||
<td style="text-align:center;color:{% if c.unreachable %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.unreachable }}</td>
|
||||
<td style="text-align:center;color:{% if c.failed %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.failed }}</td>
|
||||
<td style="text-align:center;color:var(--text-dim);">{{ c.skipped }}</td>
|
||||
</tr>
|
||||
{% for host, parts in hosts %}
|
||||
<tr><td class="mono">{{ host }}</td><td>{{ words(parts) if parts else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if run.results.failures %}
|
||||
<div class="section-title" style="margin:1rem 0 0.4rem;">Failures</div>
|
||||
<pre style="margin:0;padding:0.75rem;background:var(--bg);border-radius:4px;font-size:0.75rem;color:var(--red);max-height:240px;overflow:auto;white-space:pre-wrap;">{{ run.results.failures | join("\n") }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if run.results and run.results.failures %}
|
||||
<section class="card" aria-labelledby="run-failures">
|
||||
<h2 id="run-failures" class="section-title">What failed</h2>
|
||||
<pre class="run-log run-failures">{{ run.results.failures | join("\n") }}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
<div style="padding:0.75rem 1rem;background:var(--bg-elevated);border-bottom:1px solid var(--border);">
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">Output</span>
|
||||
</div>
|
||||
<section class="card-flush" aria-labelledby="run-output">
|
||||
<h2 id="run-output" class="table-caption">Output</h2>
|
||||
{% if run.status.value in ("running", "queued") %}
|
||||
<pre id="live-output"
|
||||
style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
|
||||
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:var(--text-muted);">
|
||||
Streaming…
|
||||
</div>
|
||||
<pre id="live-output" class="run-log">{{ run.output or "" }}</pre>
|
||||
<p id="run-done-status" class="field-hint run-log-status">Streaming…</p>
|
||||
<script>
|
||||
(function() {
|
||||
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||
@@ -97,7 +94,7 @@
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<pre style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--text-muted);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
|
||||
<pre class="run-log">{{ run.output or "(no output)" }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ansible/_tabs.html" import ansible_tabs %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% block title %}Ansible Schedules — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Schedules", "")]) }}{% endblock %}
|
||||
@@ -9,13 +10,10 @@
|
||||
{% 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 style="display:flex;gap:0.5rem;">
|
||||
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
|
||||
<a href="/ansible/browse" class="btn btn-ghost">Browse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Existing schedules ───────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -8,7 +8,10 @@ from quart import render_template, session
|
||||
|
||||
from steward.ansible.inventory_gen import run_title
|
||||
from steward.ansible.origins import describe_origin
|
||||
from steward.ansible.routes import hosts_worst_first, run_duration, run_view
|
||||
from steward.ansible.recap import host_recap, run_recap
|
||||
from steward.ansible.routes import (
|
||||
RUN_ORIGINS, RUN_STATUSES, hosts_worst_first, next_scheduled, run_duration, run_filters, run_view,
|
||||
)
|
||||
from steward.models.ansible import AnsibleRunStatus
|
||||
from tests.test_style_tokens import hex_colours
|
||||
|
||||
@@ -24,14 +27,14 @@ def test_a_run_is_named_by_what_ran_where(label, title):
|
||||
assert run_title("update.yml", label) == title
|
||||
|
||||
|
||||
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False):
|
||||
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False, results=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
started = now - timedelta(minutes=started_min_ago)
|
||||
return SimpleNamespace(
|
||||
id=rid, playbook_path="update.yml", source_name="builtin", inventory_scope=scope,
|
||||
status=status, started_at=started,
|
||||
finished_at=started + timedelta(seconds=took_s) if took_s is not None else None,
|
||||
params={"check": True} if check else None, results=None, output="")
|
||||
params={"check": True} if check else None, results=results, output="")
|
||||
|
||||
|
||||
def test_a_running_run_counts_its_duration_so_far():
|
||||
@@ -41,56 +44,145 @@ def test_a_running_run_counts_its_duration_so_far():
|
||||
assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95
|
||||
|
||||
|
||||
def _ctx(runs, verdict=None, f=None, more_url=None):
|
||||
return {"runs": runs, "more_url": more_url, "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
|
||||
"f": f or {"q": "", "status": "", "origin": ""}, "filtered": bool(f),
|
||||
"verdict": verdict or {"days": 30, "total": 0, "failed": 0, "running": 0,
|
||||
"latest_failed": None, "next": None}}
|
||||
|
||||
|
||||
async def test_the_runs_list_names_each_run_with_one_badge(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
labels = {"steward:target:t1": "target web-1", "steward:all": "all inventory targets"}
|
||||
two = {"hosts": {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 1}}}
|
||||
runs = [run_view(r, labels, now) for r in (
|
||||
_run("aaaa1111", AnsibleRunStatus.running, 2),
|
||||
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True),
|
||||
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40),
|
||||
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True,
|
||||
results={"hosts": {"db-1": {"ok": 1, "failed": 1}, "web-1": {"ok": 2}}}),
|
||||
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40, results=two),
|
||||
)]
|
||||
more = "/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"
|
||||
async with app.test_request_context("/ansible/"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x")
|
||||
page = await render_template("ansible/index.html", **_ctx(runs, more_url=more))
|
||||
|
||||
main = page[page.index("<main"):]
|
||||
assert '<tr class="row-link">' in main # the whole row opens the run
|
||||
assert '<a class="row-target" href="/ansible/runs/aaaa1111">update.yml on web-1</a>' in main
|
||||
assert ('<a class="row-target" href="/ansible/runs/aaaa1111" title="update.yml on web-1">'
|
||||
'update.yml on web-1</a>') in main
|
||||
assert "steward:target:t1" not in main # no raw scope, not even as a tooltip
|
||||
assert ">update.yml on all inventory targets</a>" in main
|
||||
assert '<span class="badge badge-info">Running</span>' in main
|
||||
assert '<span class="badge badge-crit">Failed</span>' in main
|
||||
assert '<span class="badge badge-ok">Succeeded</span>' in main
|
||||
# Under each status, what the run did across its hosts (#5329).
|
||||
assert '<span class="tone-crit">failed on db-1</span> · 2 hosts' in main
|
||||
assert "2 hosts · 2 changed" in main
|
||||
# The list continues itself as the reader scrolls (#5211): no cap of 50.
|
||||
assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main
|
||||
assert f'data-scroll-more hx-get="{more}"' in main
|
||||
assert "2m 5s" in main and "40s" in main and " so far" in main
|
||||
assert "dry run" in main and "ago" in main
|
||||
assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
async def test_ansible_pages_share_one_strip_of_tabs(app):
|
||||
async with app.test_request_context("/ansible/"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("ansible/index.html", **_ctx([]))
|
||||
nav = page[page.index('<nav class="section-tabs" aria-label="Ansible">'):]
|
||||
nav = nav[:nav.index("</nav>")]
|
||||
for href in ("/ansible/", "/ansible/browse", "/ansible/schedules", "/ansible/inventory/targets"):
|
||||
assert f'href="{href}"' in nav, href
|
||||
assert nav.count('aria-current="page"') == 1 and '<a href="/ansible/" aria-current="page">Runs</a>' in nav
|
||||
assert "No runs yet." in page and "No runs in the last 30 days" in page
|
||||
|
||||
|
||||
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)}}
|
||||
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 '<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
|
||||
assert "No runs match these filters." in worse and 'value="x"' in worse
|
||||
|
||||
|
||||
def test_the_run_filters_drop_what_they_do_not_know():
|
||||
assert run_filters({"q": " prune ", "status": "failed", "origin": "mcp"}) == \
|
||||
{"q": "prune", "status": "failed", "origin": "mcp"}
|
||||
assert run_filters({"status": "exploded", "origin": "aliens"}) == {"q": "", "status": "", "origin": ""}
|
||||
|
||||
|
||||
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,
|
||||
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([], now) is None
|
||||
|
||||
|
||||
def test_a_runs_recap_reads_in_words_worst_first():
|
||||
hosts = {"web-1": {"ok": 3, "changed": 1}, "web-2": {"ok": 3, "changed": 0},
|
||||
"db-1": {"ok": 1, "failed": 1}, "nas": {"unreachable": 1}}
|
||||
assert run_recap({"hosts": hosts}) == [
|
||||
("failed on db-1", "crit"), ("nas unreachable", "crit"), ("4 hosts", None), ("1 changed", None)]
|
||||
assert run_recap({"hosts": {"a": {"ok": 2}}}) == [("1 host", None), ("nothing changed", "muted")]
|
||||
assert run_recap({"hosts": {"a": {"changed": 1}}}, check=True) == [("1 host", None), ("1 would change", None)]
|
||||
many = {h: {"failed": 1} for h in ("a", "b", "c", "d")}
|
||||
assert run_recap({"hosts": many})[0] == ("failed on a, b and 2 more", "crit")
|
||||
assert run_recap(None) == [] and run_recap({"hosts": {}}) == []
|
||||
|
||||
|
||||
def test_a_hosts_recap_leaves_out_zeros():
|
||||
assert host_recap({"ok": 3, "changed": 1, "unreachable": 0, "failed": 0, "skipped": 2}) == [
|
||||
("3 ok", None), ("1 changed", None), ("2 skipped", "muted")]
|
||||
assert host_recap({"ok": 1, "failed": 1, "unreachable": 1}) == [
|
||||
("unreachable", "crit"), ("1 failed", "crit"), ("1 ok", None)]
|
||||
assert host_recap({"changed": 2}, check=True) == [("2 would change", None)]
|
||||
|
||||
|
||||
async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600)
|
||||
hosts = hosts_worst_first({"hosts": {
|
||||
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
|
||||
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}})
|
||||
async with app.test_request_context("/ansible/runs/dddd4444"):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template(
|
||||
"ansible/run_detail.html", run=run, row=run_view(run, {"steward:target:t1": "target web-1"}, now),
|
||||
origin={"text": "schedule nightly", "url": "/ansible/schedules", "kind": "schedule"},
|
||||
hosts=hosts_worst_first({"hosts": {
|
||||
"alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0},
|
||||
"zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}}))
|
||||
hosts=[(h, host_recap(c)) for h, c in hosts])
|
||||
|
||||
assert "<title>update.yml on web-1 — Steward</title>" in page
|
||||
assert 'href="/ansible/"' in page # breadcrumb back to the runs
|
||||
assert '<span class="badge badge-warn">Interrupted</span>' in page
|
||||
assert "<dd>10m</dd>" in page
|
||||
# One verdict line (#5330): how it ended, how long, who started it.
|
||||
verdict = page[page.index('<p class="page-verdict">'):]
|
||||
verdict = verdict[:verdict.index("</p>")]
|
||||
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
|
||||
# #5212: who started it, by name; no run id in the page; the failed host first.
|
||||
assert '<dd><a href="/ansible/schedules">schedule nightly</a></dd>' in page
|
||||
assert "dddd4444" not in page.replace("/ansible/runs/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
|
||||
assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran
|
||||
main = page[page.index("<main"):]
|
||||
assert 'style="' not in main[:main.index('id="run-output"')] # no hand-set colours
|
||||
assert not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Integration: the Ansible Runs tab (#5329) against real Postgres. Its
|
||||
verdict counts the last 30 days; the filter bar narrows by playbook, status
|
||||
and origin; an HTMX request from the bar gets the table alone, and the next
|
||||
page as the reader scrolls keeps the filters. Requires STEWARD_DATABASE_URL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
def test_the_runs_tab_counts_and_filters(app):
|
||||
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
uid = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def run(name, status, mins, origin=None, by=None, results=None):
|
||||
return AnsibleRun(playbook_path=f"it{tag}/{name}.yml", source_name="steward-builtin",
|
||||
inventory_scope="steward:all", status=status, origin=origin,
|
||||
triggered_by=by, started_at=now - timedelta(minutes=mins),
|
||||
finished_at=now - timedelta(minutes=mins - 1), results=results)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=f"runs-{tag}", email=f"runs-{tag}@test",
|
||||
password_hash="x", role=UserRole.viewer, is_active=True))
|
||||
async with s.begin():
|
||||
s.add_all([
|
||||
run("prune", AnsibleRunStatus.success, 10, origin="schedule",
|
||||
results={"hosts": {"a": {"ok": 2, "changed": 1}, "b": {"ok": 2, "changed": 1}}}),
|
||||
run("update", AnsibleRunStatus.failed, 20, by=uid,
|
||||
results={"hosts": {f"db-{tag}": {"ok": 1, "failed": 1}}}),
|
||||
])
|
||||
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role="viewer", username=f"runs-{tag}", login_at=time.time())
|
||||
|
||||
async def get(url, **kw):
|
||||
resp = await client.get(url, **kw)
|
||||
return resp.status_code, await resp.get_data(as_text=True)
|
||||
|
||||
out = {"page": await get(f"/ansible/?q=it{tag}"),
|
||||
"failed": await get(f"/ansible/?q=it{tag}&status=failed", headers={"HX-Request": "true"}),
|
||||
"sched": await get(f"/ansible/?q=it{tag}&origin=schedule", headers={"HX-Request": "true"}),
|
||||
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"})}
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
from sqlalchemy import delete
|
||||
await s.execute(delete(AnsibleRun).where(AnsibleRun.playbook_path.like(f"it{tag}/%")))
|
||||
await s.delete(await s.get(User, uid))
|
||||
return tag, out
|
||||
|
||||
tag, out = asyncio.run(_go())
|
||||
status, page = out["page"]
|
||||
assert status == 200
|
||||
assert '<p class="page-verdict">' in page and "in the last 30 days" in page # the verdict queries ran
|
||||
assert f"it{tag}/prune.yml" in page and f"it{tag}/update.yml" in page
|
||||
assert "2 hosts · 2 changed" in page and f'<span class="tone-crit">failed on db-{tag}</span>' in page
|
||||
assert '<nav class="section-tabs" aria-label="Ansible">' in page
|
||||
|
||||
failed = out["failed"][1]
|
||||
assert "<html" not in failed and '<p class="page-verdict">' not in failed # the table alone
|
||||
assert f"it{tag}/update.yml" in failed and f"it{tag}/prune.yml" not in failed
|
||||
sched = out["sched"][1]
|
||||
assert f"it{tag}/prune.yml" in sched and f"it{tag}/update.yml" not in sched
|
||||
person = out["person"][1]
|
||||
assert f"it{tag}/update.yml" in person and f"it{tag}/prune.yml" not in person
|
||||
Reference in New Issue
Block a user