feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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>")]
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user