Files
FabledSteward/tests/core/test_alerts_page.py
T
bvandeusenandClaude Opus 5.5 3a772716ae
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Failing after 1m14s
CI / publish (push) Skipped
feat(ui): audit, alert rules and the rule form say things in words (#5212)
#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 1:

- Audit log: each action reads as a sentence ("paused monitor"), with the
  code as its tooltip. Detail shows flags as yes/no, lists joined, a run's
  scope by name, spans as spans, and no ids the entity column already gives.
  The filter is now a picker of areas.
- Alert rules, history and Status say the condition in words: "is down",
  "Fullest disk >= 85%". Readings use metric_value, and history times use
  |ago.
- Rule form: sources and metrics by name, the threshold's unit, "fires
  when it is down/up" for an up/down metric instead of 0.5, and a metric
  reference built from the catalog.
- Schedules and Maintenance: viewers no longer see Run now, Pause, Edit,
  Delete or New. Intervals and next runs read as "every 2h" and "in 3h";
  a scope reads "Every alert on Host agent".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:23:02 -04:00

208 lines
11 KiB
Python

"""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)
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 <a href="/hosts/h1">web-1</a> is 94% (&gt; 90%), firing for 12m' in page
assert '<span class="badge badge-crit">Firing</span>' in page
assert 'action="/alerts/r1/acknowledge"' in page
assert 'href="/alerts/rules/new"' in page and 'href="/alerts/rules/r1/edit"' in page
assert 'action="/alerts/rules/r1/delete"' not in page # deleting a rule is the admin's
main = page[page.index("<main"):] # the sidebar brand mark carries its own colours
assert "onclick" not in main and not hex_colours(main), hex_colours(main)
async def test_a_viewer_reads_but_gets_no_actions(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "viewer"
page = await render_template("alerts/list.html", active=[_row(rule, "firing", None)],
rules=[rule], operators=[])
assert "CPU on <strong>web-1</strong>" 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_an_admin_can_delete_a_rule(app):
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
async def _active_count(_db):
return 3
async def test_the_sidebar_counts_alerts_needing_attention(app, monkeypatch):
monkeypatch.setattr(alert_svc, "active_count", _active_count)
async with app.test_request_context("/alerts/"):
session["user_id"], session["user_role"] = "u1", "viewer"
page = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert '<span class="count-badge" title="3 alerts need attention">3</span>' 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 "<strong>nas</strong> is down, breaching for 1h" in page
assert "Acknowledged by bryan <time" in page and ">3m ago</time>" 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</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 '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"],
metrics=["is_up", "response_ms"], ansible_sources=[],
catalog={"http": ["is_up", "response_ms"], "snmp": []})
assert ">HTTP monitors</option>" in page and ">Host agent</option>" in page
assert ">Up or down</option>" in page and 'data-unit="ms"' in page
assert '<option value="down" selected>' in page # the binary choice, shown
assert 'name="threshold" step="any" required disabled' in page # the raw threshold, off
assert ">is_up<" not in page and "SNMP" not in page # no raw key; empty source skipped