feat(alerts): a covering rule's hosts fold away behind its "N hosts" (#5483)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m7s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m9s

#5483 "Alerts list: a covering rule's hosts start collapsed behind a toggle;
no dashed rows", from the operator's review of the Alerts list. Each rule is
its own tbody; its hosts sit in another, hidden until "N hosts" is pressed.
A rule with a host that isn't quiet opens on its own and names the host where
"last fired" was, so a problem never hides behind the fold. Host rows lose the
dashed rules for a sunken band under the indent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:40:17 -04:00
co-authored by Claude Opus 5.5
parent 731e7cd6e6
commit a642b4c37c
4 changed files with 55 additions and 11 deletions
+6 -1
View File
@@ -139,6 +139,7 @@ async def list_alerts():
# pressing of their states.
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
children: dict[str, list] = {}
uneasy: dict[str, list[str]] = {}
for r in rules:
if r.parent_id:
children.setdefault(r.parent_id, []).append(r)
@@ -149,11 +150,15 @@ async def list_alerts():
rule_state[parent_id] = min(
(lb for lb in labels if lb and lb not in ("inactive", "resolved")),
key=lambda lb: alert_svc.ACTIVE_ORDER.get(lb, 9), default="inactive")
# The hosts that aren't quiet: named on the rule's row, and they keep
# its hosts open (#5483).
uneasy[parent_id] = [k.resource_name for k, lb in zip(kids, labels)
if k.enabled and lb and lb not in ("inactive", "resolved")]
fired[parent_id] = max((fired[k.id] for k in kids if k.id in fired), default=None)
rules = sorted((r for r in rules if not r.parent_id),
key=lambda r: (not r.enabled, r.name.lower()))
return await render_template(
"alerts/list.html", active=active, rules=rules, children=children,
"alerts/list.html", active=active, rules=rules, children=children, uneasy=uneasy,
rule_state=rule_state, last_fired={k: v for k, v in fired.items() if v},
notify=await _notify(), operators=_OPERATORS,
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
+14 -3
View File
@@ -186,7 +186,10 @@ strong, b, th { font-weight: 500; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
/* The table's last row has no rule under it; a table may have several tbodys
(the Alerts list: one per rule, its hosts in another, maybe hidden). */
.table tbody:last-child tr:last-child td,
.table tbody:has(+ tbody[hidden]:last-child) tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* A header that sorts its column (sort_th in _macros.html): reads as the
@@ -216,9 +219,17 @@ strong, b, th { font-weight: 500; }
/* The Docker page's one-line answer, above its tiles (#5298). */
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.table tr.row-off > td { opacity: 0.55; }
/* A covering alert rule's per-host rules (#5464), indented under it. */
.table tr.row-child > td { border-top-style: dashed; }
/* A covering alert rule's per-host rules (#5464), indented on a sunken band
under it, opened by its "N hosts" toggle (#5483). */
.table tr.row-child:not(:hover) > td { background: var(--bg); }
.table tr.row-child .cell-name { padding-left: 2rem; }
.row-toggle { background: none; border: 0; padding: 0; font: inherit; color: var(--text-muted);
cursor: pointer; text-decoration: underline dotted; text-underline-offset: 2px; }
.row-toggle::before { content: "▸"; display: inline-block; margin-right: 0.25rem;
transition: transform var(--fs-dur-fast) var(--fs-ease); }
.row-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }
.row-toggle:hover { color: var(--text); }
.row-toggle:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); border-radius: var(--fs-radius-sm); }
/* The rule form's metric preview (#5475): now, then the last day beside its sparkline. */
.metric-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 1rem;
margin-top: 0.4rem; font-size: 0.85rem; }
+25 -7
View File
@@ -3,7 +3,8 @@
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. A rule covering hosts (#5464)
lists its per-host rules under it; each opens its history. #}
lists its per-host rules under it, closed behind its "N hosts" toggle
unless one of them isn't quiet (#5483); each opens its history. #}
{% extends "base.html" %}
{% block title %}Alerts — Steward{% endblock %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
@@ -35,22 +36,27 @@
<th></th>
</tr>
</thead>
{% for rule in rules %}
{% set kids = (children or {}).get(rule.id, []) %}
{% set fired = (last_fired or {}).get(rule.id) %}
{% set restless = (uneasy or {}).get(rule.id, []) %}
<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) }}
{%- if rule.covers != "resource" %} · {{ kids|length }} host{{ "s" if kids|length != 1 }}{% endif %}</div>
{%- if rule.covers != "resource" %} · <button type="button" class="row-toggle row-inner"
aria-expanded="{{ 'true' if restless else 'false' }}" aria-controls="hosts-{{ rule.id }}"
onclick="toggleHosts(this)">{{ kids|length }} host{{ "s" if kids|length != 1 }}</button>{% endif %}</div>
</td>
<td>
{%- set now_label = (rule_state or {}).get(rule.id) %}
{%- if not rule.enabled %}<span class="tone-dim">off</span>
{%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }}
{%- else %}<span class="tone-ok" title="Within its threshold">quiet</span>{% endif -%}
<div class="cell-sub">{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}</div>
<div class="cell-sub">
{%- if restless %}on {{ restless|join(", ") }}
{%- elif fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}</div>
</td>
<td>
{% 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) }}
@@ -71,6 +77,9 @@
{% endif %}
</td>
</tr>
</tbody>
{% if kids %}
<tbody id="hosts-{{ rule.id }}" class="row-children"{% if not restless %} hidden{% endif %}>
{% for kid in kids %}
{% set kid_label = (rule_state or {}).get(kid.id) %}
{% set kid_fired = (last_fired or {}).get(kid.id) %}
@@ -88,10 +97,19 @@
<td class="td-actions"><a href="/alerts/history?rule={{ kid.id }}" class="btn btn-sm btn-ghost">History</a></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
<script>
// A covering rule's hosts (#5483): closed unless one of them isn't quiet.
function toggleHosts(btn) {
var open = btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
document.getElementById(btn.getAttribute('aria-controls')).hidden = !open;
}
</script>
{% else %}
<div class="card">{{ empty_state("No alert rules yet.", "New rule" if can_edit else none, "/alerts/rules/new" if can_edit else none) }}</div>
{% endif %}
+10
View File
@@ -292,7 +292,11 @@ async def test_a_rule_covering_hosts_lists_its_hosts_under_it(app):
session["user_role"] = "operator"
listing = await render_template(
"alerts/list.html", active=[], rules=[parent], children={"p1": kids},
uneasy={"p1": ["node-a"]},
rule_state={"p1": "firing", "k0": "firing", "k1": "inactive"}, operators=[])
quiet = await render_template(
"alerts/list.html", active=[], rules=[parent], children={"p1": kids},
uneasy={"p1": []}, rule_state={"p1": "inactive"}, operators=[])
page = await render_template(
"alerts/rules_form.html", rule=parent, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=[], metrics=["seconds_since_seen"],
@@ -305,6 +309,12 @@ async def test_a_rule_covering_hosts_lists_its_hosts_under_it(app):
assert listing.index("Agent offline</a>") < listing.index('href="/alerts/history?rule=k0"') \
< listing.index('href="/alerts/history?rule=k1"')
assert 'class="row-link row-child"' in listing
# #5483: the hosts sit behind the rule's toggle, open while one isn't quiet.
assert 'aria-expanded="true" aria-controls="hosts-p1"' in listing and "on node-a" in listing
assert '<tbody id="hosts-p1" class="row-children">' in listing
quiet = " ".join(quiet.split())
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
assert 'name="covers" value="group"' in page and "fixed once the rule exists" in page
assert 'data-cover-resource hidden' in page