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. # pressing of their states.
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in 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] = {} children: dict[str, list] = {}
uneasy: dict[str, list[str]] = {}
for r in rules: for r in rules:
if r.parent_id: if r.parent_id:
children.setdefault(r.parent_id, []).append(r) children.setdefault(r.parent_id, []).append(r)
@@ -149,11 +150,15 @@ async def list_alerts():
rule_state[parent_id] = min( rule_state[parent_id] = min(
(lb for lb in labels if lb and lb not in ("inactive", "resolved")), (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") 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) 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), rules = sorted((r for r in rules if not r.parent_id),
key=lambda r: (not r.enabled, r.name.lower())) key=lambda r: (not r.enabled, r.name.lower()))
return await render_template( 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}, rule_state=rule_state, last_fired={k: v for k, v in fired.items() if v},
notify=await _notify(), operators=_OPERATORS, notify=await _notify(), operators=_OPERATORS,
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60)) 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 { 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 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 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 tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; } .table .td-actions { text-align: right; white-space: nowrap; }
/* A header that sorts its column (sort_th in _macros.html): reads as the /* 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). */ /* The Docker page's one-line answer, above its tiles (#5298). */
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; } .page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.table tr.row-off > td { opacity: 0.55; } .table tr.row-off > td { opacity: 0.55; }
/* A covering alert rule's per-host rules (#5464), indented under it. */ /* A covering alert rule's per-host rules (#5464), indented on a sunken band
.table tr.row-child > td { border-top-style: dashed; } 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; } .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. */ /* 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; .metric-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 1rem;
margin-top: 0.4rem; font-size: 0.85rem; } 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 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 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) 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" %} {% extends "base.html" %}
{% block title %}Alerts — Steward{% endblock %} {% block title %}Alerts — Steward{% endblock %}
{% from "_macros.html" import page_header, status_badge, empty_state %} {% from "_macros.html" import page_header, status_badge, empty_state %}
@@ -35,22 +36,27 @@
<th></th> <th></th>
</tr> </tr>
</thead> </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> <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 %}"> <tr class="row-link{% if not rule.enabled %} row-off{% endif %}">
<td class="cell-name"> <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> <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 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>
<td> <td>
{%- set now_label = (rule_state or {}).get(rule.id) %} {%- set now_label = (rule_state or {}).get(rule.id) %}
{%- if not rule.enabled %}<span class="tone-dim">off</span> {%- if not rule.enabled %}<span class="tone-dim">off</span>
{%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }} {%- 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 -%} {%- 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>
<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) }} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>
</tbody>
{% if kids %}
<tbody id="hosts-{{ rule.id }}" class="row-children"{% if not restless %} hidden{% endif %}>
{% for kid in kids %} {% for kid in kids %}
{% set kid_label = (rule_state or {}).get(kid.id) %} {% set kid_label = (rule_state or {}).get(kid.id) %}
{% set kid_fired = (last_fired 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> <td class="td-actions"><a href="/alerts/history?rule={{ kid.id }}" class="btn btn-sm btn-ghost">History</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %}
</tbody> </tbody>
{% endif %}
{% endfor %}
</table> </table>
</div> </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 %} {% 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> <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 %} {% 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" session["user_role"] = "operator"
listing = await render_template( listing = await render_template(
"alerts/list.html", active=[], rules=[parent], children={"p1": kids}, "alerts/list.html", active=[], rules=[parent], children={"p1": kids},
uneasy={"p1": ["node-a"]},
rule_state={"p1": "firing", "k0": "firing", "k1": "inactive"}, operators=[]) 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( page = await render_template(
"alerts/rules_form.html", rule=parent, operators=[], source_modules=["host_agent"], "alerts/rules_form.html", rule=parent, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=[], metrics=["seconds_since_seen"], 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"') \ assert listing.index("Agent offline</a>") < listing.index('href="/alerts/history?rule=k0"') \
< listing.index('href="/alerts/history?rule=k1"') < listing.index('href="/alerts/history?rule=k1"')
assert 'class="row-link row-child"' in listing 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 "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 'name="covers" value="group"' in page and "fixed once the rule exists" in page
assert 'data-cover-resource hidden' in page assert 'data-cover-resource hidden' in page