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">
+24 -4
View File
@@ -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
View File
@@ -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;