From a642b4c37c8e76cb952265d2029316e3a450f928 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 10:40:17 -0400 Subject: [PATCH] feat(alerts): a covering rule's hosts fold away behind its "N hosts" (#5483) #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 --- steward/alerts/routes.py | 7 ++++++- steward/static/steward.css | 17 +++++++++++++--- steward/templates/alerts/list.html | 32 +++++++++++++++++++++++------- tests/core/test_alerts_page.py | 10 ++++++++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index d0107aa..39786b7 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -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)) diff --git a/steward/static/steward.css b/steward/static/steward.css index 336e8c4..ea1841b 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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; } diff --git a/steward/templates/alerts/list.html b/steward/templates/alerts/list.html index 715b74b..338235c 100644 --- a/steward/templates/alerts/list.html +++ b/steward/templates/alerts/list.html @@ -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 @@ + {% 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, []) %} - {% for rule in rules %} - {% set kids = (children or {}).get(rule.id, []) %} - {% set fired = (last_fired or {}).get(rule.id) %} {{ rule.name }}
{{ source_label(rule.source_module) }} - {%- if rule.covers != "resource" %} · {{ kids|length }} host{{ "s" if kids|length != 1 }}{% endif %}
+ {%- if rule.covers != "resource" %} · {% endif %} {%- set now_label = (rule_state or {}).get(rule.id) %} {%- if not rule.enabled %}off {%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }} {%- else %}quiet{% endif -%} -
{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}
+
+ {%- if restless %}on {{ restless|join(", ") }} + {%- elif fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}
{% 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 %} + + {% if kids %} + {% 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 @@ History {% endfor %} - {% endfor %} + {% endif %} + {% endfor %} + {% else %}
{{ empty_state("No alert rules yet.", "New rule" if can_edit else none, "/alerts/rules/new" if can_edit else none) }}
{% endif %} diff --git a/tests/core/test_alerts_page.py b/tests/core/test_alerts_page.py index cc0ec1d..ba6bc9b 100644 --- a/tests/core/test_alerts_page.py +++ b/tests/core/test_alerts_page.py @@ -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") < 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 '' in listing + quiet = " ".join(quiet.split()) + assert 'aria-expanded="false" aria-controls="hosts-p1"' in quiet + assert '' in quiet and "never fired" in quiet assert "Covers every host in swarm: 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