feat(alerts): one rule can cover every host, or every host in a group (#5464)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m7s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m12s

#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:
2026-10-09 08:25:51 -04:00
co-authored by Claude Opus 5.5
parent 734d313a54
commit 48a0de5600
13 changed files with 647 additions and 39 deletions
+36 -3
View File
@@ -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">