#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>
291 lines
17 KiB
HTML
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 %}
|