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