Files
FabledSteward/steward/templates/alerts/rules_form.html
T
bvandeusenandClaude Opus 5.5 5ef18c992c
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s
feat(alerts): Send test on a rule checks that its alerts reach someone (#5466)
#5466 "Send test: check a rule's delivery from the rule". A rule's page gets
a Send test button (operator role), and MCP gets send_test_alert. Each sends
a TEST alert for that rule now, by email to everyone who gets alerts and by
webhook. The alert carries the rule's name and condition, at the latest
reading of what it watches; a rule covering hosts uses its first host. The
result for each channel shows under the button, or "Nothing is set up to
send to". Nothing fires, no event is written, and the test is audited as
alert_rule.tested. An alert with no reading now says "has no reading yet"
instead of "is None".

Docs: alerting.md also gains the burst email from #5465 "One email per burst
of alerts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:33:32 -04:00

293 lines
17 KiB
HTML

{# 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, 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 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>
{% if rule.covers != "resource" %}
<p class="field-hint" style="margin-top:-0.5rem;">
Covers {% if rule.covers == "group" %}every host in <strong>{{ rule.cover_group }}</strong>{% else %}every host{% endif %}:
{% if covered %}{{ covered|length }} now ({{ covered|join(", ") }}), each with its own alert and history.
{%- else %}no host yet; one is covered within a minute of reporting.{% endif %}
Changes here apply to all of them.</p>
{% endif %}
{% 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 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>
{# ── Source module ───────────────────────────────────────────────────── #}
<div class="form-group">
<label>Source</label>
<select name="source_module" required
hx-get="/alerts/api/rule_fields"
hx-trigger="change"
hx-target="#rule-fields"
hx-include="[name='source_module']">
{% for src in source_modules %}
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>{{ source_label(src) }}</option>
{% endfor %}
</select>
</div>
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %}
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
What each metric measures
</summary>
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
{% for src, names in catalog.items() if names %}
<div>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);margin-bottom:0.2rem;">{{ source_label(src) }}</div>
{% for m in names %}
<div style="display:grid;grid-template-columns:200px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<span>{{ metric_label(m) }}{% if metric_unit(m) %} <span style="color:var(--text-dim);">({{ metric_unit(m) }})</span>{% endif %}</span>
<span style="color:var(--text-muted);">{{ metric_about(m) }}</span>
</div>
{% endfor %}
</div>
{% 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>
{% if rule %}
<div class="form-group">
<button type="button" class="btn btn-sm btn-ghost" hx-post="/alerts/rules/{{ rule.id }}/test"
hx-target="#rule-test-result" hx-swap="innerHTML">Send test</button>
<p class="field-hint">Sends a TEST alert for this rule as saved, now, to everywhere above.
Nothing fires, and the rule's history doesn't change.</p>
<div id="rule-test-result" aria-live="polite"></div>
</div>
{% endif %}
<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 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 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">
<option value="">— select source —</option>
{% for s in ansible_sources %}
<option value="{{ s }}" {% if act and act.source == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Playbook <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
<input type="text" name="ansible_playbook" placeholder="playbooks/shutdown.yml"
value="{{ act.playbook if act else '' }}">
</div>
<div class="form-group">
<label>Inventory <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
<input type="text" name="ansible_inventory" placeholder="inventory/hosts"
value="{{ act.inventory if act else '' }}">
</div>
<div class="form-group">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label>
<textarea name="ansible_extra_vars" rows="2"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">{{ act.extra_vars | join("\n") if act and act.extra_vars else "" }}</textarea>
</div>
<div class="form-group">
<label>Limit to host(s) <span style="color:var(--text-muted);font-weight:normal;">(optional)</span></label>
<input type="text" name="ansible_limit" value="{{ act.limit if act and act.limit else '' }}">
</div>
<div class="form-group">
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(optional)</span></label>
<input type="text" name="ansible_tags" value="{{ act.tags if act and act.tags else '' }}">
</div>
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="ansible_check" {% if act and act.check %}checked{% endif %}>
Dry-run (--check --diff)
</label>
</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);
});
// Covering hosts swaps the resource picker for the group picker (#5464).
document.addEventListener("change", function (e) {
if (!e.target.hasAttribute("data-covers")) return;
var form = e.target.form, v = e.target.value;
var res = form.querySelector("[data-cover-resource]"), grp = form.querySelector("[data-cover-group]");
res.hidden = v !== "resource";
res.querySelectorAll("select").forEach(function (s) { s.disabled = v !== "resource" || s.dataset.none === "1"; });
grp.hidden = v !== "group";
grp.querySelector("select").disabled = v !== "group";
});
// 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).
document.addEventListener("change", function (e) {
if (e.target.name !== "metric_name") return;
var form = e.target.form, opt = e.target.selectedOptions[0];
var binary = opt.hasAttribute("data-binary"), unit = opt.dataset.unit || "";
form.querySelector("[data-cond-binary]").hidden = !binary;
form.querySelector("[data-cond-numeric]").hidden = binary;
form.elements["binary_when"].disabled = !binary;
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"];
if (opt.value === "seconds_since_seen" && threshold.value === "") {
form.elements["operator"].value = ">";
threshold.value = "180";
}
});
</script>
<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>
</section>
{% endif %}
</div>
{% endblock %}