fix(alerts): a rule's page doesn't wait for its metric preview (#5485); Send test up top (#5486)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m5s

#5485 "A rule's page waits up to 25 s for its metric preview: a cold 24 h
series read from plugin_metrics". Opening "Agent offline" took 25.6 s; a
cold day of one series measured 18.3 s over MCP, a warm one 63 ms. The
pickers still come with the page from plugin_metric_latest; the preview is
its own request (hx-trigger=load), showing "Reading the last day…" until it
lands. Picker swaps no longer read it either.

#5486 "Send test where it can be found": the button moves from inside "What
happens" to the page header beside History, its result under the verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:44:19 -04:00
co-authored by Claude Opus 5.5
parent a642b4c37c
commit 73cfbda1da
7 changed files with 40 additions and 23 deletions
+3 -2
View File
@@ -81,7 +81,8 @@ whole host", then its mounts, CPU cores, disks and so on, under a heading for
each kind. A part that reports nothing a rule can watch, such as an interface's
traffic, isn't listed. The **Metric** list holds only what the picked resource
reports. Under it, a preview shows the metric's current reading and its last
24 hours (lowest to highest, with an hourly sparkline), so the threshold can be
24 hours (lowest to highest, with an hourly sparkline; it loads just after the
page, since a day of samples can take a while to read), so the threshold can be
set against real numbers.
### Rules that cover hosts
@@ -176,7 +177,7 @@ bursts: each alert posts on its own, at once.
### Send test
**Send test** on a rule's page (operator role), or `send_test_alert` over MCP,
**Send test** at the top of a rule's page (operator role), or `send_test_alert` over MCP,
sends a `TEST` alert for that rule now, by email and webhook, the same way a
real alert would go. It uses the rule's name and condition and the latest
reading of what the rule watches. A rule that covers hosts uses its first
+13 -9
View File
@@ -281,9 +281,11 @@ async def rule_target_api():
@alerts_bp.get("/api/metric_preview")
@require_role(UserRole.viewer)
async def metric_preview_api():
"""What the picked metric reads now and over the last day (#5475)."""
"""What the picked metric reads now and over the last day (#5475). Its own
request, after the page: a cold day of one series can take many seconds
to read (#5485), and the page doesn't wait for it."""
async with current_app.db_sessionmaker() as db:
ctx = await _target_from_args(db)
ctx = await _target_from_args(db, with_preview=True)
return await render_template("alerts/_metric_preview.html", **ctx)
@@ -300,28 +302,30 @@ async def cover_groups_api():
async def _target(db, source: str, *, host: str = "", resource: str = "", metric: str = "",
covers: str = "resource", keep: bool = False) -> dict:
"""The rule form's target pickers and the chosen metric's preview (#5475);
`keep` on a rule's own page, so its resource and metric stay on offer."""
covers: str = "resource", keep: bool = False, with_preview: bool = False) -> dict:
"""The rule form's target pickers (#5475), from the latest-value table;
`keep` on a rule's own page, so its resource and metric stay on offer.
The chosen metric's preview only `with_preview`: it reads a day of raw
samples, so pages and picker swaps leave it to its own request (#5485)."""
from steward.core.status import sparkline_svg
target = await alert_svc.rule_target(db, source, host=host, resource=resource, metric=metric,
keep=keep)
preview = None
if target["resource"] and target["metric"]:
if with_preview and target["resource"] and target["metric"]:
preview = await alert_svc.metric_preview(db, source, target["resource"], target["metric"])
if preview:
# From zero to the series' own peak (UI & Design ruling 8), so a
# disk at 61-62% reads flat rather than as a cliff.
preview["spark"] = sparkline_svg(preview["series"], width=120, height=20,
stroke="currentColor", floor=1.0)
return {**target, "preview": preview, "current_covers": covers}
return {**target, "preview": preview, "preview_read": with_preview, "current_covers": covers}
async def _target_from_args(db) -> dict:
async def _target_from_args(db, *, with_preview: bool = False) -> dict:
a = request.args
return await _target(db, a.get("source_module", ""), host=a.get("pick_host", ""),
resource=a.get("resource_name", ""), metric=a.get("metric_name", ""),
covers=a.get("covers", "resource"))
covers=a.get("covers", "resource"), with_preview=with_preview)
async def _cover_choices(db, source: str, rule: AlertRule | None = None) -> dict:
@@ -13,6 +13,8 @@
</span>
{% endif %}
</div>
{% elif metric and resource and not preview_read %}
<p class="field-hint">Reading the last day…</p>
{% elif metric and resource %}
<p class="field-hint">No reading of this yet from {{ resource }}.</p>
{% endif %}
+8 -2
View File
@@ -3,7 +3,8 @@
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. #}
(/alerts/api/rule_target); picking a metric swaps the preview, which
is read in its own request after the block is drawn (#5485). #}
{% set one = current_covers|default("resource") == "resource" %}
<div data-cover-resource {% if not one %}hidden{% endif %}>
{% if host_source %}
@@ -68,5 +69,10 @@
{% 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>
{# The preview comes in its own request once the page is up (#5485). #}
<div id="metric-preview" aria-live="polite"
{%- if metric and resource %} hx-get="/alerts/api/metric_preview" hx-trigger="load"
hx-include="closest form" hx-params="source_module,resource_name,pick_host,metric_name,covers"{% endif %}>
{%- include "alerts/_metric_preview.html" -%}
</div>
</div>
+7 -9
View File
@@ -10,6 +10,10 @@
<div class="form-page">
{% if rule %}
{% call page_header(rule.name) %}
{# Send test (#5466), up here where it is found (#5486); its result shows under the verdict. #}
<button type="button" class="btn btn-sm btn-ghost" hx-post="/alerts/rules/{{ rule.id }}/test"
hx-target="#rule-test-result" hx-swap="innerHTML"
title="Send a TEST alert for this rule as saved, now. Nothing fires and its history doesn't change.">Send test</button>
<a class="btn btn-sm btn-ghost" href="/alerts/history?rule={{ rule.id }}">History</a>
{% endcall %}
<p class="page-verdict">
@@ -17,6 +21,7 @@
{%- 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>
<div id="rule-test-result" aria-live="polite"></div>
{% 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 %}:
@@ -139,15 +144,8 @@
{%- 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 %}
{% if rule %}<p class="field-hint" style="margin-top:-0.5rem;">To check that it arrives, use
<strong>Send test</strong> at the top of this page.</p>{% 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"
+3
View File
@@ -316,5 +316,8 @@ async def test_a_rule_covering_hosts_lists_its_hosts_under_it(app):
assert 'aria-expanded="false" aria-controls="hosts-p1"' in quiet
assert '<tbody id="hosts-p1" class="row-children" hidden>' in quiet and "never fired" in quiet
assert "Covers every host in <strong>swarm</strong>: 2 now (node-a, node-b)" in page
# #5486: Send test sits in the page's header, its result under the verdict.
assert page.index('hx-post="/alerts/rules/p1/test"') < page.index('id="rule-test-result"') \
< page.index('id="rule-watches"')
assert 'name="covers" value="group"' in page and "fixed once the rule exists" in page
assert 'data-cover-resource hidden' in page
+4 -1
View File
@@ -51,11 +51,14 @@ def test_the_pickers_and_the_preview_read_what_the_host_reports(app):
_csrf="test-token", login_at=time.time())
resp = await client.get("/alerts/api/rule_target", query_string={
"source_module": "host_agent", "pick_host": host})
"source_module": "host_agent", "pick_host": host, "metric_name": "cpu_pct"})
page = " ".join((await resp.get_data(as_text=True)).split())
assert resp.status_code == 200
assert f'<option value="{host}" selected>The whole host</option>' in page
assert f'<optgroup label="Mounts"> <option value="{host}:/data" >/data</option>' in page
# #5485: the pickers come without the preview, which loads in its own request.
assert 'hx-get="/alerts/api/metric_preview" hx-trigger="load"' in page
assert "Reading the last day" in page and "Now <strong>" not in page
resp = await client.get("/alerts/api/metric_preview", query_string={
"source_module": "host_agent", "pick_host": host, "resource_name": f"{host}:/data",