Files
FabledSteward/steward/templates/alerts/rules_form.html
T
bvandeusenandClaude Opus 5.5 d19df51ffb
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m8s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
feat(alerts): pick the host, then its part, and see what the metric reads (#5475)
#5475 "Rule form: pick the host, then its part; metrics that part reports;
a link to groups; a preview of the metric", from the operator's review of
the New rule page: hosts and their parts shared one long picker.

- Host agent rules pick a Host, then a Part: "The whole host", then its
  mounts, CPU cores, disks, interfaces and sensors under a heading each,
  cores in number order. A part that reports nothing a rule can watch
  (an interface's traffic) isn't offered.
- The Metric list holds what the picked resource reports, in catalog order.
- Under it, a preview: the reading now, and the last 24 h low to high with
  an hourly sparkline drawn from zero (UI ruling 8), read from one series.
- The group picker links to Ansible > Inventory to make a group, in a new
  tab, and re-reads the groups when the tab comes back into view.
- Picking a host or part swaps the pickers alone (/alerts/api/rule_target);
  picking a metric swaps the preview (/alerts/api/metric_preview).
- A rule's own resource and metric stay on offer after they go quiet.

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

291 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">
{% 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 %}
<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 %}