feat(alerts): pick the host, then its part, and see what the metric reads (#5475)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m8s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped

#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>
This commit is contained in:
2026-10-09 09:54:08 -04:00
co-authored by Claude Opus 5.5
parent d5601575d8
commit d19df51ffb
12 changed files with 453 additions and 73 deletions
@@ -0,0 +1,9 @@
{# alerts/_cover_groups.html — the inventory groups a rule can cover (#5464),
re-read when the reader returns from making one (#5475). #}
<select id="rule-group" name="cover_group" {% if current_covers|default("resource") != "group" %}disabled{% endif %}>
{% for g in groups %}
<option value="{{ g }}" {% if g == current_group|default("") %}selected{% endif %}>{{ g }}</option>
{% else %}
<option value="" disabled selected>No inventory groups yet</option>
{% endfor %}
</select>
@@ -0,0 +1,18 @@
{# alerts/_metric_preview.html — what the chosen metric reads (#5475), so a
threshold is set against real numbers: now, and the last day's hourly
averages with the lowest and highest reading. A rule covering hosts
previews on the host picked above it, or none. #}
{% if preview %}
<div class="metric-preview">
<span>Now <strong>{{ metric_value(metric, preview.now) }}</strong>
<span class="tone-dim" title="{{ preview.at|when }}">{{ preview.at|ago }}</span></span>
{% if preview.series|length > 1 %}
<span class="metric-preview-day">
<span class="tone-dim">last 24 h</span> {{ metric_value(metric, preview.low) }} – {{ metric_value(metric, preview.high) }}
<span class="metric-preview-spark" aria-hidden="true">{{ preview.spark|safe }}</span>
</span>
{% endif %}
</div>
{% elif metric and resource %}
<p class="field-hint">No reading of this yet from {{ resource }}.</p>
{% endif %}
+19 -43
View File
@@ -1,8 +1,10 @@
{# 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.
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. #}
{# alerts/_rule_fields.html — what a rule watches, for one source; 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. The host,
part and metric pickers are alerts/_rule_target.html, swapped on their
own as a host or part is picked (#5475). #}
{% set covers = current_covers|default("resource") %}
{% set one = covers == "resource" %}
{% if host_source|default(false) %}
@@ -16,7 +18,7 @@
<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>
<option value="group" {% if covers == "group" %}selected{% endif %}>Every host in a group</option>
</select>
{% endif %}
<p class="field-hint">Covering hosts keeps one alert per host under this rule, each named
@@ -25,43 +27,17 @@
</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>
{# Re-read when the reader comes back to this tab, so a group just made in
Inventory is here without losing what was typed. #}
<div id="rule-group-picker" hx-get="/alerts/api/cover_groups" hx-trigger="visibilitychange[document.visibilityState === 'visible'] from:document"
hx-include="[name='cover_group'],[name='covers']" hx-swap="innerHTML">
{% include "alerts/_cover_groups.html" %}
</div>
<p class="field-hint">A host is in a group through its linked Ansible target.
<a href="/ansible/inventory/?add=group" target="_blank" rel="noopener">Make a group, or change who is in one, in Ansible → Inventory</a>;
it shows here when you come back.</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 {% 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 data-none="1">
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">
The list fills from readings this source has recorded. Come back once it has reported.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric</label>
<select name="metric_name" required data-metric-picker>
{% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == current_metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if current_metric and metric_about(current_metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(current_metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
<div id="rule-target">
{% include "alerts/_rule_target.html" %}
</div>
@@ -0,0 +1,72 @@
{# alerts/_rule_target.html — what one rule watches (#5475). For a host
source: the Host, then its Part ("the whole host", then mounts, cores and
so on), so a host's parts never crowd the list of hosts. Any other source:
one Resource picker. Then the metrics that resource reports, and a preview
of the chosen one. Picking a host or part swaps this block alone
(/alerts/api/rule_target); picking a metric swaps the preview. #}
{% set one = current_covers|default("resource") == "resource" %}
<div data-cover-resource {% if not one %}hidden{% endif %}>
{% if host_source %}
<div class="form-pair">
<div class="form-group">
<label for="rule-host">Host</label>
{% if hosts %}
<select id="rule-host" name="pick_host" {% if not one %}disabled{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,pick_host,metric_name,covers">
{% for h in hosts %}<option value="{{ h }}" {% if h == host %}selected{% endif %}>{{ h }}</option>{% endfor %}
</select>
{% else %}
<select id="rule-host" disabled data-none="1"><option>No host has reported yet</option></select>
{% endif %}
</div>
<div class="form-group">
<label for="rule-part">Part</label>
<select id="rule-part" name="resource_name" required {% if not one or not parts %}disabled{% endif %} {% if not parts %}data-none="1"{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,pick_host,resource_name,metric_name,covers">
{% for heading, items in parts %}
{% if heading %}<optgroup label="{{ heading }}">{% endif %}
{% for value, label in items %}<option value="{{ value }}" {% if value == resource %}selected{% endif %}>{{ label }}</option>{% endfor %}
{% if heading %}</optgroup>{% endif %}
{% endfor %}
</select>
</div>
</div>
{% if not hosts %}<p class="field-hint" style="margin-top:-0.5rem;">The list fills once an agent reports.</p>{% endif %}
{% else %}
<div class="form-group">
<label for="rule-resource">Resource</label>
{% if resources %}
<select id="rule-resource" name="resource_name" required {% if not one %}disabled{% endif %}
hx-get="/alerts/api/rule_target" hx-trigger="change" hx-target="#rule-target"
hx-include="closest form" hx-params="source_module,resource_name,metric_name,covers">
{% if not resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}<option value="{{ r }}" {% if r == resource %}selected{% endif %}>{{ r }}</option>{% endfor %}
</select>
{% else %}
<select id="rule-resource" name="resource_name" required disabled data-none="1">
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">The list fills from readings this source has recorded. Come back once it has reported.</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="rule-metric">Metric</label>
<select id="rule-metric" name="metric_name" required data-metric-picker
hx-get="/alerts/api/metric_preview" hx-trigger="change" hx-target="#metric-preview"
hx-include="closest form" hx-params="source_module,resource_name,pick_host,metric_name,covers">
{% if not metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if metric and metric_about(metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
<div id="metric-preview" aria-live="polite">{% include "alerts/_metric_preview.html" %}</div>
</div>
-2
View File
@@ -56,9 +56,7 @@
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %}
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">