feat(alerts): one rule can cover every host, or every host in a group (#5464)
#5464 "Group rules: one rule covers every host, or every host in a group", step 2 of milestone 499 "Alerts you can trust". Each rule named one host, so the live instance carried six rules for three hosts, and a new host had no agent-offline or disk alert until someone copied them. - alert_rules.covers / cover_group / parent_id (migration 0041). A rule on host_agent may cover "all_hosts" or a "group" (an inventory group: the Hosts ruling, #5294). A covering rule has no state; Steward keeps one ordinary child rule per covered host, named "<rule>: <host>", so each host's alert keeps its own state, history and acknowledgement. - alerts.service.reconcile_children: a covered host that has reported in the last 7 days gets a child; a host no longer covered loses its child; children take the parent's condition, checks, notify setting, action and on/off. Run on save and each minute (alert_reconcile). - A child is changed through its parent: edit, toggle and delete of a child are refused (409 / PartOfCoveringRule); its edit page opens the parent. - Rule form: a Covers choice for host sources, with the group picker; fixed once the rule exists. Rules list: a covering rule reads "every host in swarm · 3 hosts" with its hosts' rules nested under it, each opening its history. The history picker and the rule count leave covering rules out. - MCP: create_alert_rule takes covers/group; update_alert_rule takes group and passes changes down; list_alert_rules shows covers, hosts and part_of. - docs/core/alerting.md: "Rules that cover hosts". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,49 @@
|
||||
{# alerts/_rule_fields.html — the resource and metric pickers for one source;
|
||||
the rule form includes it and swaps it in again when the source changes. #}
|
||||
the rule form includes it and swaps it in again when the source changes.
|
||||
For a source whose resources are hosts, the rule can instead cover every
|
||||
host, or every host in an inventory group (#5464): Steward then keeps one
|
||||
rule per host under it. What a rule covers is fixed once it exists. #}
|
||||
{% set covers = current_covers|default("resource") %}
|
||||
{% set one = covers == "resource" %}
|
||||
{% if host_source|default(false) %}
|
||||
<div class="form-group">
|
||||
<label for="rule-covers">Covers</label>
|
||||
{% if covers_locked|default(false) %}
|
||||
<input type="hidden" name="covers" value="{{ covers }}">
|
||||
<p style="margin:0;">{% if one %}One host, or one part of a host{% elif covers == "all_hosts" %}Every host{% else %}Every host in a group{% endif %}
|
||||
<span class="tone-dim">· fixed once the rule exists</span></p>
|
||||
{% else %}
|
||||
<select id="rule-covers" name="covers" data-covers>
|
||||
<option value="resource" {% if one %}selected{% endif %}>One host, or one part of a host</option>
|
||||
<option value="all_hosts" {% if covers == "all_hosts" %}selected{% endif %}>Every host</option>
|
||||
<option value="group" {% if covers == "group" %}selected{% endif %} {% if not groups %}disabled{% endif %}>Every host in a group{% if not groups %} (no inventory groups yet){% endif %}</option>
|
||||
</select>
|
||||
{% endif %}
|
||||
<p class="field-hint">Covering hosts keeps one alert per host under this rule, each named
|
||||
"rule: host". A host that starts reporting, or joins the group, is covered within a minute;
|
||||
one that leaves loses its alert and that alert's history.</p>
|
||||
</div>
|
||||
<div class="form-group" data-cover-group {% if covers != "group" %}hidden{% endif %}>
|
||||
<label for="rule-group">Group</label>
|
||||
<select id="rule-group" name="cover_group" {% if covers != "group" %}disabled{% endif %}>
|
||||
{% for g in groups %}
|
||||
<option value="{{ g }}" {% if g == current_group|default("") %}selected{% endif %}>{{ g }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-hint">A host is in a group through its linked Ansible target (Ansible → Inventory).</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group" data-cover-resource {% if not one %}hidden{% endif %}>
|
||||
<label>Resource</label>
|
||||
{% if resources %}
|
||||
<select name="resource_name" required>
|
||||
<select name="resource_name" required {% if not one %}disabled{% endif %}>
|
||||
{% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
|
||||
{% for r in resources %}
|
||||
<option value="{{ r }}" {% if r == current_resource %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<select name="resource_name" required disabled>
|
||||
<select name="resource_name" required disabled data-none="1">
|
||||
<option value="">Nothing has reported from this source yet</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
(alerts/_active.html, polled), then every rule: what it is doing now and
|
||||
when it last fired, what it fires on, and its History and Disable. The
|
||||
whole row opens the rule (.row-link, its name is the link); deleting one is
|
||||
on its own page, behind the confirm dialog. #}
|
||||
on its own page, behind the confirm dialog. A rule covering hosts (#5464)
|
||||
lists its per-host rules under it; each opens its history. #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alerts — Steward{% endblock %}
|
||||
{% from "_macros.html" import page_header, status_badge, empty_state %}
|
||||
@@ -36,11 +37,13 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in rules %}
|
||||
{% set kids = (children or {}).get(rule.id, []) %}
|
||||
{% set fired = (last_fired or {}).get(rule.id) %}
|
||||
<tr class="row-link{% if not rule.enabled %} row-off{% endif %}">
|
||||
<td class="cell-name">
|
||||
<a class="row-target" href="{{ ('/alerts/rules/' ~ rule.id ~ '/edit') if can_edit else ('/alerts/history?rule=' ~ rule.id) }}" title="{{ rule.name }}">{{ rule.name }}</a>
|
||||
<div class="cell-sub">{{ source_label(rule.source_module) }}</div>
|
||||
<div class="cell-sub">{{ source_label(rule.source_module) }}
|
||||
{%- if rule.covers != "resource" %} · {{ kids|length }} host{{ "s" if kids|length != 1 }}{% endif %}</div>
|
||||
</td>
|
||||
<td>
|
||||
{%- set now_label = (rule_state or {}).get(rule.id) %}
|
||||
@@ -50,7 +53,7 @@
|
||||
<div class="cell-sub">{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ rule.resource_name }} · {{ rule_condition(rule) }}
|
||||
{% if rule.covers == "group" %}every host in {{ rule.cover_group }}{% elif rule.covers == "all_hosts" %}every host{% else %}{{ rule.resource_name }}{% endif %} · {{ rule_condition(rule) }}
|
||||
<div class="cell-sub">
|
||||
{%- set n_fire, n_ok = rule.consecutive_failures_required, rule.consecutive_ok_required %}
|
||||
{%- if n_fire > 1 %}after {{ n_fire }} checks in a row{% else %}on the first check{% endif %}
|
||||
@@ -58,7 +61,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<a href="/alerts/history?rule={{ rule.id }}" class="btn btn-sm btn-ghost">History</a>
|
||||
{% if rule.covers == "resource" %}<a href="/alerts/history?rule={{ rule.id }}" class="btn btn-sm btn-ghost">History</a>{% endif %}
|
||||
{% if can_edit %}
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">
|
||||
@@ -68,6 +71,23 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for kid in kids %}
|
||||
{% set kid_label = (rule_state or {}).get(kid.id) %}
|
||||
{% set kid_fired = (last_fired or {}).get(kid.id) %}
|
||||
<tr class="row-link row-child{% if not kid.enabled %} row-off{% endif %}">
|
||||
<td class="cell-name">
|
||||
<a class="row-target" href="/alerts/history?rule={{ kid.id }}" title="{{ kid.name }}">{{ kid.resource_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{%- if not kid.enabled %}<span class="tone-dim">off</span>
|
||||
{%- elif kid_label and kid_label not in ('inactive', 'resolved') %}{{ status_badge(kid_label) }}
|
||||
{%- else %}<span class="tone-ok" title="Within its threshold">quiet</span>{% endif -%}
|
||||
<div class="cell-sub">{% if kid_fired %}last fired {{ kid_fired|ago }}{% else %}never fired{% endif %}</div>
|
||||
</td>
|
||||
<td class="tone-dim">as above</td>
|
||||
<td class="td-actions"><a href="/alerts/history?rule={{ kid.id }}" class="btn btn-sm btn-ghost">History</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
{%- 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 %}
|
||||
@@ -212,6 +219,16 @@
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user