feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m25s
CI / publish (push) Skipped

Confirm dialog (#5314):
- Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js)
  instead of window.confirm.
- The question becomes the heading and what follows it the detail; the act's
  button is named after the form's own button; Cancel has the focus and Esc
  backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes.
- All 27 existing uses work as they are. Cancelling a run says 'Keep running'.

Alerts list (#5315):
- A verdict line (what is firing, how many rules, where notifications go,
  or 'notifies nobody yet' with a link) replaces the empty Active card.
- Each rule's name opens it; rows show 'last fired' and fold fire/clear into
  the condition; History and Disable stay; Delete leaves the list.
- .docker-verdict becomes the shared .page-verdict.

Rule page (#5317):
- Named for the rule (title and crumb), with when it last fired and how many
  changes of state it holds.
- Three sections: what it watches, when it fires (checks side by side,
  seconds echoed as a duration), what happens (where notifications go, no-data,
  the playbook, whose fields stay folded until ticked).
- Delete at the foot, behind the dialog, saying its history goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:58:38 -04:00
co-authored by Claude Opus 5.5
parent 7cb6cb1fb5
commit 0c66c1e7d3
14 changed files with 401 additions and 131 deletions
+64 -5
View File
@@ -104,13 +104,72 @@ async def test_a_viewer_reads_but_gets_no_actions(app):
assert 'href="/alerts/history?rule=r1"' in page
async def test_an_admin_can_delete_a_rule(app):
async def test_deleting_a_rule_is_on_its_own_page_and_says_what_goes(app):
"""#5317: the list offers no Delete; the rule's page does, behind the
confirm dialog, saying its history goes with it."""
rule = _rule()
rule.ansible_action, rule.notify_no_data = None, True
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
listing = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=["web-1"], metrics=["cpu_pct"], ansible_sources=[],
catalog={}, history_count=12, fired=None, notify=[])
assert "/delete" not in listing
assert 'action="/alerts/rules/r1/delete"' in page
assert ('data-confirm="Delete rule web-1 cpu_pct? Its history goes with it (12 changes of state). '
'Disabling the rule keeps it."') in page
assert page.index('id="rule-watches"') < page.index('id="rule-fires"') < page.index('id="rule-happens"') \
< page.index('id="rule-delete"')
async def test_the_rule_page_is_named_for_the_rule(app):
"""#5317: the title and crumb are the rule's name; seconds read back as a
duration; the playbook's fields wait for their box; where alerts go is said."""
rule = _rule("seconds_since_seen", AlertOperator.gt, 180.0, "VDNT-Docker02")
rule.name, rule.ansible_action, rule.notify_no_data = "Agent offline: VDNT-Docker02", None, True
async with app.test_request_context("/alerts/rules/r1/edit"):
session["user_role"] = "admin"
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[(">", ">")], source_modules=["host_agent"],
initial_source="host_agent", resources=["VDNT-Docker02"], metrics=["seconds_since_seen"],
ansible_sources=[], catalog={}, history_count=10,
fired=datetime.now(timezone.utc) - timedelta(hours=6), notify=["email to me@example.com"])
page = " ".join(page.split())
assert '<h1 class="page-title">Agent offline: VDNT-Docker02</h1>' in page
assert '<span class="breadcrumb-cur" aria-current="page">Agent offline: VDNT-Docker02</span>' in page
assert "Edit Rule" not in page and "Last fired <time" in page and "10 changes of state on record" in page
assert 'value="180" placeholder="90">' in page and "data-threshold-echo >= 3m</p>" in page
assert "data-ansible-fields hidden" in page
assert "It notifies email to me@example.com." in page
def test_where_alerts_go_is_said_as_the_dispatcher_decides():
"""#5315: email needs a server and recipients; a webhook its URL."""
assert alert_svc.notify_targets({}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
== ["email to a@x, b@x", "a webhook to hooks.example"]
async def test_the_page_opens_on_a_verdict_and_says_where_alerts_go(app):
"""#5315: no card for an empty Active list; one line instead."""
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
page = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
assert 'action="/alerts/rules/r1/delete"' in page
assert 'data-confirm="Delete rule web-1 cpu_pct?"' in page
quiet = await render_template("alerts/list.html", active=[], rules=[rule], operators=[], notify=[])
firing = await render_template("alerts/list.html", active=[_row(rule, "firing", None)], rules=[rule],
operators=[], notify=["a webhook to hooks.example"],
last_fired={"r1": datetime.now(timezone.utc) - timedelta(minutes=12)})
quiet, firing = " ".join(quiet.split()), " ".join(firing.split())
assert '<strong class="tone-ok">Nothing is firing</strong> · 1 rule ·' in quiet
assert "notifies nobody yet</span> — <a href=\"/settings/notifications/\">set up email or a webhook</a>" in quiet
assert "empty-state" not in quiet and ">Active</h2>" not in quiet
assert '<strong class="tone-crit">1 firing</strong>' in firing and "notifies a webhook to hooks.example" in firing
assert "last fired <time" in firing and "never fired" in quiet
assert "on the first check" in quiet
async def _active_count(_db):
@@ -163,7 +222,7 @@ async def test_rules_say_what_each_is_doing_now(app):
rules = page[page.index(">Rules</h2>"):]
assert '<span class="badge badge-crit">Firing</span>' in rules
assert 'title="Within its threshold">quiet</span>' in rules
assert '<span class="tone-dim" style="font-size:0.8rem;">off</span>' in rules
assert '<span class="tone-dim">off</span>' in rules and 'class="row-off"' in rules
assert 'hx-get="/alerts/active"' in page
+1 -1
View File
@@ -73,7 +73,7 @@ async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
containers=[_crow(loop, exit_failed=True), _crow(old, stale=True)], containers_total=2)
# #5298: the verdict first; each problem section carries its own count (#5214).
assert page.index('class="docker-verdict"') < page.index("Needs attention")
assert page.index('class="page-verdict"') < page.index("Needs attention")
assert "1 needs attention" in page and "1 container running, 1 stopped" in page
assert " unhealthy</span>" not in page and "Failed in the last 24h" not in page and "stat-strip" not in page
card = page[page.index("Needs attention"):page.index("</section>")]
+9 -1
View File
@@ -38,6 +38,14 @@ def test_no_template_renders_a_value_into_an_inline_handler():
def test_base_asks_before_a_data_confirm_form_submits():
"""#5314: in Steward's own dialog, filled as text, never the browser's confirm()."""
base = (_REPO / "steward" / "templates" / "base.html").read_text()
assert "dataset.confirm" in base and "window.confirm(ask)" in base
js = (_REPO / "steward" / "static" / "confirm.js").read_text()
assert "filename='confirm.js'" in base and '<dialog id="confirm-dialog"' in base
assert '<form method="dialog">' in base
assert re.search(r'<button type="submit"[^>]*value="back"[^>]*autofocus>', base) # Cancel has the focus
assert re.search(r'<button type="submit"[^>]*btn-danger[^>]*value="go"', base)
assert "dataset.confirm" in js and "getElementById('confirm-dialog')" in js and "requestSubmit" in js
assert "textContent = title" in js and "innerHTML" not in js # text, never markup
assert "window.confirm" not in js and "window.confirm" not in base
assert any("data-confirm=" in p.read_text() for p in _templates())