feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m25s
CI / publish (push) Skipped

Confirm dialog (#5314):
- Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js)
  instead of window.confirm.
- The question becomes the heading and what follows it the detail; the act's
  button is named after the form's own button; Cancel has the focus and Esc
  backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes.
- All 27 existing uses work as they are. Cancelling a run says 'Keep running'.

Alerts list (#5315):
- A verdict line (what is firing, how many rules, where notifications go,
  or 'notifies nobody yet' with a link) replaces the empty Active card.
- Each rule's name opens it; rows show 'last fired' and fold fire/clear into
  the condition; History and Disable stay; Delete leaves the list.
- .docker-verdict becomes the shared .page-verdict.

Rule page (#5317):
- Named for the rule (title and crumb), with when it last fired and how many
  changes of state it holds.
- Three sections: what it watches, when it fires (checks side by side,
  seconds echoed as a duration), what happens (where notifications go, no-data,
  the playbook, whose fields stay folded until ticked).
- Delete at the foot, behind the dialog, saying its history goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:58:38 -04:00
co-authored by Claude Opus 5.5
parent 7cb6cb1fb5
commit 0c66c1e7d3
14 changed files with 401 additions and 131 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
{# ── Verdict ──────────────────────────────────────────────────────────────── #}
{% set problems = attention|length + failing_services|length %}
<p class="docker-verdict">
<p class="page-verdict">
{% if problems %}<strong class="tone-crit">{{ problems }} need{{ "s" if problems == 1 }} attention</strong>
{% else %}<strong class="tone-ok">Nothing needs attention</strong>{% endif %}
{%- if services_total %} · <span class="tone-{{ 'ok' if services_healthy == services_total else 'warn' }}">{{ services_healthy }}/{{ services_total }}</span> services healthy{% endif %}
+1 -1
View File
@@ -15,7 +15,7 @@
{% for g in swarms %}
{% set down_nodes = g.nodes|rejectattr("ok")|list %}
<section class="swarm-group" aria-label="Swarm reported by {{ g.managers|join(', ') }}">
<p class="docker-verdict">
<p class="page-verdict">
{% if down_nodes or g.troubled %}<strong class="tone-crit">{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention</strong>
{% else %}<strong class="tone-ok">Every node is up and taking work</strong>{% endif %}
· {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }}
+23 -7
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from steward.core.audit import log_audit
from sqlalchemy import select
from sqlalchemy import func, select
from steward.auth.middleware import require_role
from steward.alerts import service as alert_svc
from steward.core.paging import PAGE_SIZE, next_page
from steward.core.time_range import parse_range
from steward.ansible import sources as src_module
from steward.models.alerts import AlertRule, AlertOperator, AlertState, MaintenanceWindow
from steward.models.alerts import AlertEvent, AlertRule, AlertOperator, AlertState, MaintenanceWindow
from steward.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
@@ -102,29 +102,41 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
return action, None
def _notify() -> list[str]:
"""Where alerts' notifications go now, from the settings the app runs with."""
return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}))
@alerts_bp.get("/")
@require_role(UserRole.viewer)
async def list_alerts():
"""The verdict and what is active, then every rule with what it is doing
now and when it last fired (#5315)."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
rules = await alert_svc.list_rules(db)
states = {s.rule_id: s for s in (await db.execute(select(AlertState))).scalars()}
fired = await alert_svc.last_fired(db)
# Every rule with what it is doing now; disabled rules last.
rules.sort(key=lambda r: (not r.enabled, r.name.lower()))
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
return await render_template(
"alerts/list.html", active=active, rules=rules, rule_state=rule_state,
operators=_OPERATORS, poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
notify=_notify(), operators=_OPERATORS,
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
@alerts_bp.get("/active")
@require_role(UserRole.viewer)
async def active_fragment():
"""The Active section alone, which the alerts page polls."""
"""The verdict and Active section alone, which the alerts page polls."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
return await render_template("alerts/_active.html", active=active)
rules = await alert_svc.list_rules(db)
return await render_template(
"alerts/_active.html", active=active, notify=_notify(), rules_total=len(rules),
rules_off=sum(1 for r in rules if not r.enabled))
def _history_row(event, rule) -> dict:
@@ -215,6 +227,7 @@ async def new_rule():
metrics=alert_svc.metric_catalog().get(first_source, []),
catalog=alert_svc.metric_catalog(),
ansible_sources=_ansible_source_names(),
notify=_notify(),
)
@@ -227,9 +240,12 @@ async def edit_rule(rule_id: str):
if rule is None:
return "Not found", 404
resources = await alert_svc.resources_for(db, rule.source_module)
history_count = (await db.execute(
select(func.count()).select_from(AlertEvent).where(AlertEvent.rule_id == rule.id))).scalar_one()
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
return await render_template(
"alerts/rules_form.html",
rule=rule,
rule=rule, history_count=history_count, fired=fired, notify=_notify(),
operators=_OPERATORS,
source_modules=alert_svc.source_modules(),
initial_source=rule.source_module,
+28
View File
@@ -251,6 +251,34 @@ async def active_count(db) -> int:
)).scalar_one()
def notify_targets(smtp: dict, webhook: dict) -> list[str]:
"""Where an alert's notifications go, in words, by the same tests
core.notifications.dispatch_notifications sends by: email when a server
and recipients are set, a webhook when its URL is. Empty: nobody (#5315)."""
from urllib.parse import urlsplit
out = []
recipients = smtp.get("recipients") or []
if isinstance(recipients, str):
recipients = [r.strip() for r in recipients.split(",") if r.strip()]
if smtp.get("host") and recipients:
out.append("email to " + ", ".join(recipients))
if url := webhook.get("url"):
out.append("a webhook to " + (urlsplit(url).hostname or url))
return out
async def last_fired(db, rule_id: str | None = None) -> dict[str, datetime]:
"""{rule id: when it last went to firing}, for the rules that ever have;
one rule's when `rule_id` is given."""
stmt = (select(AlertEvent.rule_id, func.max(AlertEvent.transitioned_at))
.where(AlertEvent.to_state == AlertStateEnum.firing.value)
.group_by(AlertEvent.rule_id))
if rule_id:
stmt = stmt.where(AlertEvent.rule_id == rule_id)
rows = await db.execute(stmt)
return {rid: at for rid, at in rows.all()}
async def list_rules(db) -> list[AlertRule]:
return list((await db.execute(select(AlertRule).order_by(AlertRule.name))).scalars())
+51
View File
@@ -0,0 +1,51 @@
// Steward's own confirm dialog (#5314): a form, or its submit button, with
// data-confirm="Question? What follows." asks in the #confirm-dialog that
// base.html renders before it submits, never through window.confirm.
//
// The prompt is attribute text read through dataset and written with
// textContent, never parsed as HTML or JavaScript, so a name inside it
// cannot run as script (#5099). Optional attributes beside data-confirm:
// data-confirm-title the heading (default: the prompt up to its first "? ")
// data-confirm-detail the line under it (default: the rest of the prompt)
// data-confirm-action the button that goes ahead (default: the submit
// button's own label, or the prompt's first word)
// data-confirm-dismiss the button that backs out (default: "Cancel")
// Cancel has the focus, and Esc backs out.
(function () {
function label(btn, title) {
var own = btn ? (btn.dataset.confirmAction || btn.textContent || btn.value || '').trim() : '';
if (own.length > 2) return own; // "×" and other icons say nothing
return title.split(' ')[0].replace(/\?$/, '');
}
function ask(form, btn) {
var src = btn && btn.dataset.confirm ? btn : form;
var prompt = src.dataset.confirm;
var cut = prompt.indexOf('? ');
var title = src.dataset.confirmTitle || (cut > 0 ? prompt.slice(0, cut + 1) : prompt);
var detail = src.dataset.confirmDetail || (cut > 0 ? prompt.slice(cut + 2) : '');
var d = document.getElementById('confirm-dialog');
d.querySelector('[data-part="title"]').textContent = title;
var p = d.querySelector('[data-part="detail"]');
p.textContent = detail;
p.hidden = !detail;
d.querySelector('[data-part="go"]').textContent = src.dataset.confirmAction || label(btn, title);
d.querySelector('[data-part="back"]').textContent = src.dataset.confirmDismiss || 'Cancel';
d.returnValue = '';
d.onclose = function () {
if (d.returnValue !== 'go') return;
form.dataset.confirmed = '1';
form.requestSubmit(btn && btn.form === form ? btn : undefined);
};
d.showModal();
}
document.addEventListener('submit', function (ev) {
var form = ev.target, btn = ev.submitter;
if (!((btn && btn.dataset.confirm) || form.dataset.confirm)) return;
if (form.dataset.confirmed === '1') { delete form.dataset.confirmed; return; } // the asked-for submit
ev.preventDefault();
ev.stopPropagation(); // before HTMX sees it: an hx-post form waits for the answer too
ask(form, btn);
}, true);
})();
+16 -1
View File
@@ -208,7 +208,8 @@ strong, b, th { font-weight: 500; }
.cell-sub { font-size: 0.75rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table td.cell-image { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-muted); }
/* The Docker page's one-line answer, above its tiles (#5298). */
.docker-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.table tr.row-off > td { opacity: 0.55; }
/* A problem section's heading carries its own count, so no tile repeats it (#5214). */
.section-count { margin-bottom: 0.6rem; }
.section-count > span:first-child { margin-left: 0.25rem; }
@@ -373,6 +374,20 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
border: 1px solid var(--border); border-radius: var(--fs-radius-md); box-shadow: var(--fs-shadow-3); }
.modal::backdrop { background: var(--fs-overlay); }
.modal-title { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 500; margin: 0 0 1rem; }
/* The confirm dialog (#5314): a question, what follows from it, then Cancel and the act. */
.modal-confirm { width: min(440px, calc(100vw - 2rem)); }
.modal-confirm .modal-title { margin-bottom: 0.5rem; overflow-wrap: anywhere; }
.confirm-detail { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.25rem; overflow-wrap: anywhere; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
/* A form page in sections (#5317): capped, left-aligned under its crumbs. */
.form-page { max-width: 760px; }
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
.form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; }
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
/* A filled bar for a percentage; the fill takes its grade from status_class. */
.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; }
+19 -4
View File
@@ -1,9 +1,26 @@
{# alerts/_active.html — the Alerts page's Active section, polled on its own:
{# alerts/_active.html — the Alerts page's verdict and Active section, polled
on their own (#5315): one line that says what is firing, how many rules
there are and where their notifications go; then the alerts themselves,
firing first, then no data, then acknowledged, then pending
(alert_svc.active_rows). An acknowledged alert says how long it has been
breaching, and apart from that who acknowledged it and when. #}
{% from "_macros.html" import status_badge, empty_state %}
{% from "_macros.html" import status_badge %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{% set words = {"firing": "firing", "no_data": "silent", "acknowledged": "acknowledged", "pending": "pending"} %}
<p class="page-verdict">
{%- set firing = active|selectattr("label", "equalto", "firing")|list|length %}
{%- if firing %}<strong class="tone-crit">{{ firing }} firing</strong>
{%- elif active %}<strong class="tone-warn">Nothing is firing</strong>
{%- else %}<strong class="tone-ok">Nothing is firing</strong>{% endif %}
{%- for key in ("no_data", "acknowledged", "pending") %}
{%- set n = active|selectattr("label", "equalto", key)|list|length %}
{%- if n %} · <span class="tone-warn">{{ n }} {{ words[key] }}</span>{% endif %}
{%- endfor %}
{%- if rules_total %} · {{ rules_total }} rule{{ "s" if rules_total != 1 }}{% if rules_off %}, {{ rules_off }} off{% endif %}{% endif %}
{%- if notify %} · notifies {{ notify|join(" and ") }}
{%- else %} · <span class="tone-warn" title="No email server with recipients and no webhook URL is set, so an alert that fires tells no one">notifies nobody yet</span>
{%- if session.user_role == 'admin' %} — <a href="/settings/notifications/">set up email or a webhook</a>{% endif %}{% endif %}
</p>
{% if active %}
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
<div class="card-flush" style="margin-bottom:2rem;">
@@ -46,6 +63,4 @@
</tbody>
</table>
</div>
{% else %}
<div class="card" style="margin-bottom:2rem;">{{ empty_state("No active alerts. Every rule is quiet.") }}</div>
{% endif %}
+26 -25
View File
@@ -1,21 +1,27 @@
{# alerts/list.html — the Alerts page (#5315): the verdict and what is active
(alerts/_active.html, polled), then every rule: what it is doing now and
when it last fired, what it fires on, and its History and Disable. A rule's
name opens it; deleting one is on its own page, behind the confirm dialog. #}
{% extends "base.html" %}
{% block title %}Alerts — Steward{% endblock %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{% call page_header("Alerts", "What is breaching now, in a sentence each, then every rule.") %}
{% set rules_total = rules|length %}
{% set rules_off = rules|rejectattr("enabled")|list|length %}
{% call page_header("Alerts") %}
<a class="btn btn-ghost" href="/alerts/history">History</a>
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
{% if can_edit %}<a class="btn" href="/alerts/rules/new">New rule</a>{% endif %}
{% endcall %}
{# The Active section re-reads itself, so an alert that clears or starts shows
without a reload. #}
{# The verdict and Active section re-read themselves, so an alert that clears
or starts shows without a reload. #}
<div id="alerts-active" hx-get="/alerts/active" hx-trigger="every {{ poll_interval or 60 }}s" hx-swap="innerHTML">
{% include "alerts/_active.html" %}
</div>
<h2 class="section-title" style="margin-bottom:1rem;">Rules</h2>
<h2 class="section-title" style="margin:1.5rem 0 1rem;">Rules</h2>
{% if rules %}
<div class="card-flush">
<table class="table">
@@ -23,47 +29,42 @@
<tr>
<th>Name</th>
<th>Now</th>
<th>Condition</th>
<th style="text-align:center;" title="Checks in a row to fire / to clear">Fire / clear</th>
<th>Fires when</th>
<th></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr {% if not rule.enabled %}style="opacity:0.55;"{% endif %}>
<td>
{{ rule.name }}
{% if not rule.enabled %}
<span style="font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem;">disabled</span>
{% endif %}
{% set fired = (last_fired or {}).get(rule.id) %}
<tr{% if not rule.enabled %} class="row-off"{% endif %}>
<td class="cell-name">
<a href="{{ ('/alerts/rules/' ~ rule.id ~ '/edit') if can_edit else ('/alerts/history?rule=' ~ rule.id) }}" title="{{ rule.name }}">{{ rule.name }}</a>
<div class="cell-sub">{{ source_label(rule.source_module) }}</div>
</td>
<td>
{%- set now_label = (rule_state or {}).get(rule.id) %}
{%- if not rule.enabled %}<span class="tone-dim" style="font-size:0.8rem;">off</span>
{%- if not rule.enabled %}<span class="tone-dim">off</span>
{%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }}
{%- else %}<span class="tone-ok" style="font-size:0.8rem;" title="Within its threshold">quiet</span>{% endif -%}
{%- else %}<span class="tone-ok" title="Within its threshold">quiet</span>{% endif -%}
<div class="cell-sub">{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}</div>
</td>
<td style="color:var(--text-muted);font-size:0.85rem;">
{{ rule.resource_name }} {{ rule_condition(rule) }}
<div style="font-size:0.75rem;color:var(--text-dim);">{{ source_label(rule.source_module) }}</div>
<td>
{{ rule.resource_name }} · {{ rule_condition(rule) }}
<div class="cell-sub">
{%- set n_fire, n_ok = rule.consecutive_failures_required, rule.consecutive_ok_required %}
{%- if n_fire > 1 %}after {{ n_fire }} checks in a row{% else %}on the first check{% endif %}
{%- if n_ok > 1 %}; clears after {{ n_ok }} good ones{% endif -%}
</div>
</td>
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}</td>
<td class="td-actions">
<a href="/alerts/history?rule={{ rule.id }}" class="btn btn-sm btn-ghost">History</a>
{% if can_edit %}
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
</button>
</form>
{% endif %}
{% if session.user_role == 'admin' %}
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete rule {{ rule.name }}?">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
+146 -77
View File
@@ -1,16 +1,34 @@
{# alerts/rules_form.html — a new rule, or one rule's own page (#5317): named
for the rule, in three sections (what it watches, when it fires, what
happens), then Delete at the foot, behind the confirm dialog, saying that
its history goes with it. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Edit rule" if rule else "New rule", "")]) }}{% endblock %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ rule.name if rule else "New alert rule" }} — Alerts — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), (rule.name if rule else "New rule", "")]) }}{% endblock %}
{% block content %}
<div style="max-width:600px;margin:2rem auto;">
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
<div class="card">
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
<div class="form-page">
{% if rule %}
{% call page_header(rule.name) %}
<a class="btn btn-sm btn-ghost" href="/alerts/history?rule={{ rule.id }}">History</a>
{% endcall %}
<p class="page-verdict">
{%- if not rule.enabled %}<span class="tone-dim">Off</span> · {% endif -%}
{%- if fired %}Last fired {{ fired|ago }}{% else %}Never fired{% endif -%}
{%- if history_count is defined and history_count is not none %} · {{ history_count }} change{{ "s" if history_count != 1 }} of state on record{% endif -%}
</p>
{% else %}
{{ page_header("New alert rule") }}
{% endif %}
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
{# ── What it watches ─────────────────────────────────────────────────── #}
<section class="card form-section" aria-labelledby="rule-watches">
<h2 id="rule-watches" class="section-title">What it watches</h2>
<div class="form-group">
<label>Rule Name</label>
<input type="text" name="name" required autofocus
<label for="rule-name">Name</label>
<input type="text" id="rule-name" name="name" required {% if not rule %}autofocus{% endif %}
value="{{ rule.name if rule else '' }}"
placeholder="e.g. Home server down">
</div>
@@ -35,63 +53,6 @@
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Condition: a threshold in the metric's unit, or for an up/down metric
the word it fires on (sent as binary_when; the route turns it into
< 0.5 or >= 0.5). The script swaps the two as the metric changes. #}
{% set m0 = rule.metric_name if rule else "" %}
{% set binary = m0 in ("is_up", "is_healthy") %}
{% set fires_down = not rule or rule.operator.value in ("<", "<=") %}
<div data-cond-binary {% if not binary %}hidden{% endif %} class="form-group">
<label>Fires when it is</label>
<select name="binary_when" {% if not binary %}disabled{% endif %}>
<option value="down" {% if fires_down %}selected{% endif %}>down (unhealthy)</option>
<option value="up" {% if not fires_down %}selected{% endif %}>up (healthy)</option>
</select>
</div>
<div data-cond-numeric {% if binary %}hidden{% endif %} style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Fires when the reading is</label>
<select name="operator" {% if binary %}disabled{% endif %}>
{% for val, label in operators %}
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold <span data-threshold-unit style="color:var(--text-muted);font-weight:normal;">{% if metric_unit(m0) %}({{ metric_unit(m0) }}){% endif %}</span></label>
<input type="number" name="threshold" step="any" required {% if binary %}disabled{% endif %}
value="{{ rule.threshold if rule and not binary else '' }}" placeholder="90">
</div>
</div>
<div class="form-group">
<label>Breaching checks in a row before it fires</label>
<input type="number" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
<div class="form-group">
<label>Consecutive OK checks before it clears</label>
<input type="number" name="consecutive_ok_required" min="1"
value="{{ rule.consecutive_ok_required if rule else 1 }}">
<p class="field-hint">
A firing or acknowledged alert resolves only after this many checks in a row are back
within the threshold. An acknowledgement holds until then.</p>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="notify_no_data"
{% if not rule or rule.notify_no_data %}checked{% endif %}>
Notify when this metric stops reporting
</label>
<p class="field-hint">
Silence is always shown as NO DATA on the Alerts page: no sample for three of the
metric's usual gaps (at least 5 minutes). This also sends a notification when it starts
and when data resumes. A firing alert that goes silent stays firing.</p>
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
@@ -111,19 +72,93 @@
{% endfor %}
</div>
</details>
</section>
{# ── When it fires ───────────────────────────────────────────────────── #}
<section class="card form-section" aria-labelledby="rule-fires">
<h2 id="rule-fires" class="section-title">When it fires</h2>
{# ── Condition: a threshold in the metric's unit, or for an up/down metric
the word it fires on (sent as binary_when; the route turns it into
< 0.5 or >= 0.5). The script swaps the two as the metric changes. #}
{% set m0 = rule.metric_name if rule else "" %}
{% set binary = m0 in ("is_up", "is_healthy") %}
{% set fires_down = not rule or rule.operator.value in ("<", "<=") %}
<div data-cond-binary {% if not binary %}hidden{% endif %} class="form-group">
<label>Fires when it is</label>
<select name="binary_when" {% if not binary %}disabled{% endif %}>
<option value="down" {% if fires_down %}selected{% endif %}>down (unhealthy)</option>
<option value="up" {% if not fires_down %}selected{% endif %}>up (healthy)</option>
</select>
</div>
<div data-cond-numeric {% if binary %}hidden{% endif %} class="form-pair">
<div class="form-group">
<label>Fires when the reading is</label>
<select name="operator" {% if binary %}disabled{% endif %}>
{% for val, label in operators %}
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold <span data-threshold-unit style="color:var(--text-muted);font-weight:normal;">{% if metric_unit(m0) %}({{ metric_unit(m0) }}){% endif %}</span></label>
<input type="number" name="threshold" step="any" required {% if binary %}disabled{% endif %}
value="{{ ('%g'|format(rule.threshold)) if rule and not binary else '' }}" placeholder="90">
<p class="field-hint" data-threshold-echo {% if metric_unit(m0) != "seconds" or not rule %}hidden{% endif %}>
{%- if rule and metric_unit(m0) == "seconds" %}= {{ metric_value(m0, rule.threshold) }}{% endif %}</p>
</div>
</div>
<div class="form-pair">
<div class="form-group">
<label for="rule-fire">Checks in a row before it fires</label>
<input type="number" id="rule-fire" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
<div class="form-group">
<label for="rule-clear">Good checks in a row before it clears</label>
<input type="number" id="rule-clear" name="consecutive_ok_required" min="1"
value="{{ rule.consecutive_ok_required if rule else 1 }}">
</div>
</div>
<p class="field-hint" style="margin-top:-0.5rem;">
A check is each new reading of the metric, as its source reports it. A firing or
acknowledged alert clears only after that many readings in a row are back within the
threshold; an acknowledgement holds until then.</p>
</section>
{# ── What happens ────────────────────────────────────────────────────── #}
<section class="card form-section" aria-labelledby="rule-happens">
<h2 id="rule-happens" class="section-title">What happens</h2>
<p style="margin:0 0 0.75rem;">
{%- if notify %}It notifies {{ notify|join(" and ") }}.
{%- else %}<span class="tone-warn">It notifies nobody yet:</span> no email server with recipients and no webhook is set
{%- if session.user_role == 'admin' %} (<a href="/settings/notifications/">Settings → Notifications</a>){% endif %}.{% endif -%}
</p>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="notify_no_data"
{% if not rule or rule.notify_no_data %}checked{% endif %}>
Notify when this metric stops reporting
</label>
<p class="field-hint">
Silence is always shown as NO DATA on the Alerts page: no sample for three of the
metric's usual gaps (at least 5 minutes). This also sends a notification when it starts
and when data resumes. A firing alert that goes silent stays firing.</p>
</div>
{# ── Ansible action (admin only) ─────────────────────────────────────── #}
{% if session.get("user_role") == "admin" %}
{% set act = rule.ansible_action if rule and rule.ansible_action else None %}
<div style="margin:1.25rem 0;padding:1rem;border:1px solid var(--border-mid);border-radius:6px;background:var(--bg-elevated);">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:500;">
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %}>
On firing → run an Ansible playbook
<div class="form-group" style="margin-top:1rem;">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %} data-ansible-toggle>
Run an Ansible playbook when it fires
</label>
<p style="font-size:0.78rem;color:var(--text-muted);margin:0.4rem 0 0.75rem;">
Admin only. Runs once when the alert transitions into FIRING.
<code>steward_alert_*</code> context vars (resource, metric, value…) are injected automatically.
</p>
<p class="field-hint">
Admins only. Runs once each time the alert starts firing, with <code>steward_alert_*</code>
variables (resource, metric, value…) passed to it.</p>
</div>
<div class="ansible-fields" data-ansible-fields {% if not act %}hidden{% endif %}>
<div class="form-group">
<label>Source</label>
<select name="ansible_source">
@@ -163,7 +198,25 @@
</div>
{% endif %}
</section>
<script>
// A threshold in seconds reads back as a duration: 180 → "= 3m".
function showDuration(form) {
var s = Math.round(parseFloat(form.elements["threshold"].value)), echo = form.querySelector("[data-threshold-echo]");
if (!(s >= 0)) { echo.textContent = ""; return; }
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), r = s % 60;
var parts = [d && d + "d", h && h + "h", m && m + "m", (r || s === 0) && r + "s"].filter(Boolean);
echo.textContent = "= " + parts.slice(0, 2).join(" ");
}
document.addEventListener("input", function (e) {
if (e.target.name === "threshold" && !e.target.form.querySelector("[data-threshold-echo]").hidden) showDuration(e.target.form);
});
// The playbook's fields show only while "run a playbook" is ticked.
document.addEventListener("change", function (e) {
if (!e.target.hasAttribute("data-ansible-toggle")) return;
e.target.form.querySelector("[data-ansible-fields]").hidden = !e.target.checked;
});
// The condition follows the metric: an up/down metric asks which word
// fires, any other a threshold in its unit. "Agent offline" is
// seconds_since_seen > 180 (the agent's stale window).
@@ -177,6 +230,9 @@
form.elements["operator"].disabled = binary;
form.elements["threshold"].disabled = binary;
form.querySelector("[data-threshold-unit]").textContent = unit ? "(" + unit + ")" : "";
var echo = form.querySelector("[data-threshold-echo]");
echo.hidden = unit !== "seconds";
if (unit === "seconds") showDuration(form);
var about = form.querySelector("[data-metric-about]");
if (about) { about.textContent = opt.dataset.about || ""; about.hidden = !opt.dataset.about; }
var threshold = form.elements["threshold"];
@@ -187,11 +243,24 @@
});
</script>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
<div class="form-actions">
<button type="submit" class="btn">{% if rule %}Save changes{% else %}Create rule{% endif %}</button>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{# ── Delete: on the rule's own page, never in the list (#5317) ─────────── #}
{% if rule and session.user_role == 'admin' %}
<section class="card danger-zone" aria-labelledby="rule-delete">
<div>
<h2 id="rule-delete" class="section-title">Delete this rule</h2>
<p class="field-hint" style="margin:0.25rem 0 0;">Its history goes with it{% if history_count %} ({{ history_count }} change{{ "s" if history_count != 1 }} of state){% endif %}. Disabling it on the Alerts page keeps the history.</p>
</div>
<form method="post" action="/alerts/rules/{{ rule.id }}/delete"
data-confirm="Delete rule {{ rule.name }}? Its history goes with it{% if history_count %} ({{ history_count }} change{{ 's' if history_count != 1 }} of state){% endif %}. Disabling the rule keeps it.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Delete rule</button>
</form>
</div>
</section>
{% endif %}
</div>
{% endblock %}
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %}
{% 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?">{{ csrf_field() }}
<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>
</form>
{% endif %}
+15 -7
View File
@@ -10,13 +10,6 @@
document.addEventListener('htmx:configRequest', function (ev) {
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
});
// A form (or its submit button) with data-confirm="…" asks before it
// submits. The prompt is attribute text read through dataset, never
// parsed as JavaScript, so a name inside it cannot run as script (#5099).
document.addEventListener('submit', function (ev) {
var ask = (ev.submitter && ev.submitter.dataset.confirm) || ev.target.dataset.confirm;
if (ask && !window.confirm(ask)) { ev.preventDefault(); ev.stopPropagation(); }
}, true);
</script>
<title>{% block title %}Steward{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='logo/steward-mark.svg') }}">
@@ -28,6 +21,8 @@
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
<script src="{{ url_for('static', filename='scroll-more.js') }}" defer></script>
{# A form with data-confirm="…" asks in #confirm-dialog below before it submits (#5314). #}
<script src="{{ url_for('static', filename='confirm.js') }}" defer></script>
{% block head %}{% endblock %}
</head>
<body>
@@ -167,6 +162,19 @@ document.addEventListener('keydown', function (e) {
<div class="nav-scrim" onclick="setNavOpen(false)"></div>
{% endif %}
{# Steward's own confirm dialog (#5314): static/confirm.js fills it from the
data-confirm attributes of the form being submitted. Cancel has the focus. #}
<dialog id="confirm-dialog" class="modal modal-confirm" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-detail">
<form method="dialog">
<h2 id="confirm-dialog-title" class="modal-title" data-part="title"></h2>
<p id="confirm-dialog-detail" class="confirm-detail" data-part="detail"></p>
<div class="modal-actions">
<button type="submit" class="btn btn-sm btn-ghost" value="back" data-part="back" autofocus>Cancel</button>
<button type="submit" class="btn btn-sm btn-danger" value="go" data-part="go">Continue</button>
</div>
</form>
</dialog>
{% block extra_scripts %}{% endblock %}
</body>
+64 -5
View File
@@ -104,13 +104,72 @@ async def test_a_viewer_reads_but_gets_no_actions(app):
assert 'href="/alerts/history?rule=r1"' in page
async def test_an_admin_can_delete_a_rule(app):
async def test_deleting_a_rule_is_on_its_own_page_and_says_what_goes(app):
"""#5317: the list offers no Delete; the rule's page does, behind the
confirm dialog, saying its history goes with it."""
rule = _rule()
rule.ansible_action, rule.notify_no_data = None, True
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
listing = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=["web-1"], metrics=["cpu_pct"], ansible_sources=[],
catalog={}, history_count=12, fired=None, notify=[])
assert "/delete" not in listing
assert 'action="/alerts/rules/r1/delete"' in page
assert ('data-confirm="Delete rule web-1 cpu_pct? Its history goes with it (12 changes of state). '
'Disabling the rule keeps it."') in page
assert page.index('id="rule-watches"') < page.index('id="rule-fires"') < page.index('id="rule-happens"') \
< page.index('id="rule-delete"')
async def test_the_rule_page_is_named_for_the_rule(app):
"""#5317: the title and crumb are the rule's name; seconds read back as a
duration; the playbook's fields wait for their box; where alerts go is said."""
rule = _rule("seconds_since_seen", AlertOperator.gt, 180.0, "VDNT-Docker02")
rule.name, rule.ansible_action, rule.notify_no_data = "Agent offline: VDNT-Docker02", None, True
async with app.test_request_context("/alerts/rules/r1/edit"):
session["user_role"] = "admin"
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[(">", ">")], source_modules=["host_agent"],
initial_source="host_agent", resources=["VDNT-Docker02"], metrics=["seconds_since_seen"],
ansible_sources=[], catalog={}, history_count=10,
fired=datetime.now(timezone.utc) - timedelta(hours=6), notify=["email to me@example.com"])
page = " ".join(page.split())
assert '<h1 class="page-title">Agent offline: VDNT-Docker02</h1>' in page
assert '<span class="breadcrumb-cur" aria-current="page">Agent offline: VDNT-Docker02</span>' in page
assert "Edit Rule" not in page and "Last fired <time" in page and "10 changes of state on record" in page
assert 'value="180" placeholder="90">' in page and "data-threshold-echo >= 3m</p>" in page
assert "data-ansible-fields hidden" in page
assert "It notifies email to me@example.com." in page
def test_where_alerts_go_is_said_as_the_dispatcher_decides():
"""#5315: email needs a server and recipients; a webhook its URL."""
assert alert_svc.notify_targets({}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
== ["email to a@x, b@x", "a webhook to hooks.example"]
async def test_the_page_opens_on_a_verdict_and_says_where_alerts_go(app):
"""#5315: no card for an empty Active list; one line instead."""
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
page = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
assert 'action="/alerts/rules/r1/delete"' in page
assert 'data-confirm="Delete rule web-1 cpu_pct?"' in page
quiet = await render_template("alerts/list.html", active=[], rules=[rule], operators=[], notify=[])
firing = await render_template("alerts/list.html", active=[_row(rule, "firing", None)], rules=[rule],
operators=[], notify=["a webhook to hooks.example"],
last_fired={"r1": datetime.now(timezone.utc) - timedelta(minutes=12)})
quiet, firing = " ".join(quiet.split()), " ".join(firing.split())
assert '<strong class="tone-ok">Nothing is firing</strong> · 1 rule ·' in quiet
assert "notifies nobody yet</span> — <a href=\"/settings/notifications/\">set up email or a webhook</a>" in quiet
assert "empty-state" not in quiet and ">Active</h2>" not in quiet
assert '<strong class="tone-crit">1 firing</strong>' in firing and "notifies a webhook to hooks.example" in firing
assert "last fired <time" in firing and "never fired" in quiet
assert "on the first check" in quiet
async def _active_count(_db):
@@ -163,7 +222,7 @@ async def test_rules_say_what_each_is_doing_now(app):
rules = page[page.index(">Rules</h2>"):]
assert '<span class="badge badge-crit">Firing</span>' in rules
assert 'title="Within its threshold">quiet</span>' in rules
assert '<span class="tone-dim" style="font-size:0.8rem;">off</span>' in rules
assert '<span class="tone-dim">off</span>' in rules and 'class="row-off"' in rules
assert 'hx-get="/alerts/active"' in page
+1 -1
View File
@@ -73,7 +73,7 @@ async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
containers=[_crow(loop, exit_failed=True), _crow(old, stale=True)], containers_total=2)
# #5298: the verdict first; each problem section carries its own count (#5214).
assert page.index('class="docker-verdict"') < page.index("Needs attention")
assert page.index('class="page-verdict"') < page.index("Needs attention")
assert "1 needs attention" in page and "1 container running, 1 stopped" in page
assert " unhealthy</span>" not in page and "Failed in the last 24h" not in page and "stat-strip" not in page
card = page[page.index("Needs attention"):page.index("</section>")]
+9 -1
View File
@@ -38,6 +38,14 @@ def test_no_template_renders_a_value_into_an_inline_handler():
def test_base_asks_before_a_data_confirm_form_submits():
"""#5314: in Steward's own dialog, filled as text, never the browser's confirm()."""
base = (_REPO / "steward" / "templates" / "base.html").read_text()
assert "dataset.confirm" in base and "window.confirm(ask)" in base
js = (_REPO / "steward" / "static" / "confirm.js").read_text()
assert "filename='confirm.js'" in base and '<dialog id="confirm-dialog"' in base
assert '<form method="dialog">' in base
assert re.search(r'<button type="submit"[^>]*value="back"[^>]*autofocus>', base) # Cancel has the focus
assert re.search(r'<button type="submit"[^>]*btn-danger[^>]*value="go"', base)
assert "dataset.confirm" in js and "getElementById('confirm-dialog')" in js and "requestSubmit" in js
assert "textContent = title" in js and "innerHTML" not in js # text, never markup
assert "window.confirm" not in js and "window.confirm" not in base
assert any("data-confirm=" in p.read_text() for p in _templates())