Files
FabledSteward/steward/templates/alerts/rules_form.html
T
bvandeusenandClaude Opus 5.5 73cfbda1da
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m5s
fix(alerts): a rule's page doesn't wait for its metric preview (#5485); Send test up top (#5486)
#5485 "A rule's page waits up to 25 s for its metric preview: a cold 24 h
series read from plugin_metrics". Opening "Agent offline" took 25.6 s; a
cold day of one series measured 18.3 s over MCP, a warm one 63 ms. The
pickers still come with the page from plugin_metric_latest; the preview is
its own request (hx-trigger=load), showing "Reading the last day…" until it
lands. Picker swaps no longer read it either.

#5486 "Send test where it can be found": the button moves from inside "What
happens" to the page header beside History, its result under the verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:44:19 -04:00

289 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) %}
{# Send test (#5466), up here where it is found (#5486); its result shows under the verdict. #}
<button type="button" class="btn btn-sm btn-ghost" hx-post="/alerts/rules/{{ rule.id }}/test"
hx-target="#rule-test-result" hx-swap="innerHTML"
title="Send a TEST alert for this rule as saved, now. Nothing fires and its history doesn't change.">Send test</button>
<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>
<div id="rule-test-result" aria-live="polite"></div>
{% 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">
{% include "alerts/_rule_fields.html" %}
</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 %}<p class="field-hint" style="margin-top:-0.5rem;">To check that it arrives, use
<strong>Send test</strong> at the top of this page.</p>{% 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 %}