"""The Alerts page says each alert as a sentence around a link to what it watches, offers only the actions the viewer's role can take, and the sidebar counts what needs attention (#5124).""" from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest from quart import render_template, session from steward.alerts import service as alert_svc from steward.alerts.service import alert_sentence from steward.models.alerts import AlertOperator, AlertStateEnum from tests.test_style_tokens import hex_colours def _rule(metric="cpu_pct", op=AlertOperator.gt, threshold=90.0, resource="web-1", rid="r1"): return SimpleNamespace(id=rid, name=f"{resource} {metric}", source_module="host_agent", resource_name=resource, metric_name=metric, operator=op, threshold=threshold, enabled=True, consecutive_failures_required=1, consecutive_ok_required=1, covers="resource", cover_group=None, parent_id=None) def _said(says): return says["lead"] + says["resource"] + says["tail"] @pytest.mark.parametrize("op, threshold, value, reads", [ (AlertOperator.gt, 90.0, 94.0, "CPU on web-1 is 94% (> 90%), firing for 12m"), (AlertOperator.gte, 90.0, 90.0, "CPU on web-1 is 90% (>= 90%), firing for 12m"), (AlertOperator.lt, 5.0, 2.5, "CPU on web-1 is 2.5% (< 5.0%), firing for 12m"), (AlertOperator.lte, 5.0, 5.0, "CPU on web-1 is 5.0% (<= 5.0%), firing for 12m"), (AlertOperator.eq, 100.0, 100.0, "CPU on web-1 is 100% (== 100%), firing for 12m"), (AlertOperator.ne, 0.0, 3.0, "CPU on web-1 is 3.0% (!= 0.0%), firing for 12m"), ]) def test_a_firing_alert_reads_as_one_sentence(op, threshold, value, reads): now = datetime.now(timezone.utc) says = alert_sentence(_rule(op=op, threshold=threshold), "firing", value, now - timedelta(minutes=12), now) assert says["resource"] == "web-1" assert _said(says) == reads def test_units_follow_the_metric_name(): now = datetime.now(timezone.utc) says = alert_sentence(_rule("response_ms", threshold=200.0, resource="api"), "pending", 431.0, now - timedelta(seconds=45), now) assert _said(says) == "Response time on api is 431 ms (> 200 ms), pending for 45s" def test_an_up_or_down_metric_says_the_word(): now = datetime.now(timezone.utc) says = alert_sentence(_rule("is_up", AlertOperator.lt, 1.0, "nas"), "acknowledged", 0.0, now - timedelta(hours=2, minutes=5), now) # Acknowledged is still breaching: the sentence says for how long (#5210). assert _said(says) == "nas is down, breaching for 2h 5m" def test_no_reading_yet_is_said_not_shown_as_a_number(): now = datetime.now(timezone.utc) says = alert_sentence(_rule(), "firing", None, now - timedelta(minutes=3), now) assert _said(says) == "CPU on web-1 has no reading yet (> 90%), firing for 3m" def test_a_silent_series_says_how_long_it_has_been_quiet(): now = datetime.now(timezone.utc) says = alert_sentence(_rule(), "no_data", 50.0, now - timedelta(minutes=20), now) assert _said(says) == "No CPU reading from web-1 for 20m" def _row(rule, label, url, state=AlertStateEnum.firing): now = datetime.now(timezone.utc) st = SimpleNamespace(state=state, no_data_since=None, last_sample_at=None, last_transition_at=now - timedelta(minutes=12)) return {"state": st, "rule": rule, "label": label, "url": url, "says": alert_sentence(rule, label, 94.0, st.last_transition_at, now)} async def test_an_operator_sees_the_sentence_linked_and_can_acknowledge(app): rule = _rule() async with app.test_request_context("/alerts/"): session["user_role"] = "operator" page = await render_template("alerts/list.html", active=[_row(rule, "firing", "/hosts/h1")], rules=[rule], operators=[]) assert 'CPU on web-1 is 94% (> 90%), firing for 12m' in page assert 'Firing' in page assert 'action="/alerts/r1/acknowledge"' in page assert 'href="/alerts/rules/new"' in page and 'href="/alerts/rules/r1/edit"' in page # The whole row opens the rule: one stretched link, the row marked for it. row = page[page.index('")] assert row.count('class="row-target"') == 1 and 'class="row-target" href="/alerts/rules/r1/edit"' in row assert 'action="/alerts/rules/r1/delete"' not in page # deleting a rule is the admin's main = page[page.index("web-1" in page # nothing to link to: no bare href for action in ("/acknowledge", "/alerts/rules/new", "/edit", "/toggle", "/delete"): assert action not in page, action assert 'href="/alerts/history?rule=r1"' in page 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"], resource="web-1", metrics=["cpu_pct"], metric="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"], resource="VDNT-Docker02", metrics=["seconds_since_seen"], metric="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 '

Agent offline: VDNT-Docker02

' in page assert 'Agent offline: VDNT-Docker02' in page assert "Edit Rule" not in page and "Last fired ' in page and "data-threshold-echo >= 3m

" 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": []}, {}, accounts=3) == ["email to 3 accounts"] assert alert_svc.notify_targets({"host": "mx", "recipients": ["ops@x"]}, {}, accounts=1) == [ "email to 1 account, ops@x"] assert alert_svc.notify_targets({"host": "", "recipients": []}, {}, accounts=3) == [] # no server, no email 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" 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 'Nothing is firing · 1 rule ·' in quiet assert "notifies nobody yet — set up email or a webhook" in quiet assert "empty-state" not in quiet and ">Active" not in quiet assert '1 firing' in firing and "notifies a webhook to hooks.example" in firing assert "last fired 3' in page async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch): monkeypatch.setattr(alert_svc, "active_count", _active_count) async with app.test_request_context("/alerts/", headers={"HX-Request": "true"}): session["user_id"], session["user_role"] = "u1", "viewer" fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[]) async with app.test_request_context("/alerts/"): logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[]) assert "count-badge" not in fragment and "count-badge" not in logged_out async def test_an_acknowledged_alert_says_who_and_when_apart_from_the_breach(app): rule = _rule("is_up", AlertOperator.lt, 1.0, "nas") now = datetime.now(timezone.utc) st = SimpleNamespace(state=AlertStateEnum.acknowledged, no_data_since=None, last_sample_at=None, last_transition_at=now - timedelta(minutes=3), acknowledged_at=now - timedelta(minutes=3)) row = {"state": st, "rule": rule, "label": "acknowledged", "url": None, "acked_by": "bryan", "says": alert_sentence(rule, "acknowledged", 0.0, now - timedelta(hours=1), now)} async with app.test_request_context("/alerts/active"): session["user_role"] = "operator" page = await render_template("alerts/_active.html", active=[row]) page = " ".join(page.split()) assert "nas is down, breaching for 1h" in page assert "Acknowledged by bryan 3m ago" in page assert "/acknowledge" not in page # already taken async def test_rules_say_what_each_is_doing_now(app): firing, quiet, off = _rule(rid="r1"), _rule(rid="r2", resource="db"), _rule(rid="r3", resource="old") off.enabled = False async with app.test_request_context("/alerts/"): session["user_role"] = "viewer" page = await render_template("alerts/list.html", active=[], rules=[firing, quiet, off], rule_state={"r1": "firing", "r2": "inactive"}, operators=[]) page = " ".join(page.split()) rules = page[page.index(">Rules"):] assert 'Firing' in rules assert 'title="Within its threshold">quiet' in rules assert 'off' in rules and 'class="row-link row-off"' in rules assert 'hx-get="/alerts/active"' in page def test_a_rule_condition_reads_in_words(): """#5212: "is down", not "is_up < 0.5"; a threshold in its unit.""" from steward.alerts.service import rule_condition assert rule_condition(_rule("is_up", AlertOperator.lt, 0.5)) == "is down" assert rule_condition(_rule("is_healthy", AlertOperator.gte, 0.5)) == "is healthy" assert rule_condition(_rule("disk_used_pct_worst", AlertOperator.gte, 85.0)) == "Fullest disk >= 85%" assert rule_condition(_rule("seconds_since_seen", AlertOperator.gt, 180.0)) == \ "Time since the agent reported > 3m" assert rule_condition(_rule("hrProcessorLoad", AlertOperator.gt, 3.0)) == "hrProcessorLoad > 3" def test_the_form_turns_up_or_down_into_a_condition(): from steward.alerts.routes import _form_condition assert _form_condition({"metric_name": "is_up", "binary_when": "down"}) == \ {"operator": "<", "threshold": "0.5"} assert _form_condition({"metric_name": "is_healthy", "binary_when": "up"}) == \ {"operator": ">=", "threshold": "0.5"} # Any other metric keeps what was typed, whatever binary_when says. assert _form_condition({"metric_name": "cpu_pct", "binary_when": "down", "operator": ">", "threshold": "90"}) == {"operator": ">", "threshold": "90"} async def test_the_rule_form_speaks_in_names_and_units(app): from steward.alerts.routes import _OPERATORS rule = _rule("is_up", AlertOperator.lt, 0.5, "nas") rule.source_module, rule.ansible_action, rule.notify_no_data = "http", None, True async with app.test_request_context("/alerts/rules/r1/edit"): session["user_role"] = "operator" page = await render_template( "alerts/rules_form.html", rule=rule, operators=_OPERATORS, source_modules=["http", "host_agent"], initial_source="http", resources=["nas"], resource="nas", metrics=["is_up", "response_ms"], metric="is_up", ansible_sources=[], catalog={"http": ["is_up", "response_ms"], "snmp": []}) assert ">HTTP monitors" in page and ">Host agent" in page assert ">Up or down" in page and 'data-unit="ms"' in page assert '