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>
This commit is contained in:
@@ -25,12 +25,12 @@ def _said(says):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("op, threshold, value, reads", [
|
||||
(AlertOperator.gt, 90.0, 94.0, "cpu_pct on web-1 is 94% (> 90%), firing for 12m"),
|
||||
(AlertOperator.gte, 90.0, 90.0, "cpu_pct on web-1 is 90% (>= 90%), firing for 12m"),
|
||||
(AlertOperator.lt, 5.0, 2.5, "cpu_pct on web-1 is 2.5% (< 5.0%), firing for 12m"),
|
||||
(AlertOperator.lte, 5.0, 5.0, "cpu_pct on web-1 is 5.0% (<= 5.0%), firing for 12m"),
|
||||
(AlertOperator.eq, 100.0, 100.0, "cpu_pct on web-1 is 100% (== 100%), firing for 12m"),
|
||||
(AlertOperator.ne, 0.0, 3.0, "cpu_pct on web-1 is 3.0% (!= 0.0%), firing for 12m"),
|
||||
(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)
|
||||
@@ -44,7 +44,7 @@ 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_ms on api is 431 ms (> 200 ms), pending for 45s"
|
||||
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():
|
||||
@@ -58,13 +58,13 @@ def test_an_up_or_down_metric_says_the_word():
|
||||
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_pct on web-1 has no reading yet (> 90%), firing for 3m"
|
||||
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_pct from web-1 for 20m"
|
||||
assert _said(says) == "No CPU reading from web-1 for 20m"
|
||||
|
||||
|
||||
def _row(rule, label, url, state=AlertStateEnum.firing):
|
||||
@@ -82,7 +82,7 @@ async def test_an_operator_sees_the_sentence_linked_and_can_acknowledge(app):
|
||||
page = await render_template("alerts/list.html", active=[_row(rule, "firing", "/hosts/h1")],
|
||||
rules=[rule], operators=[])
|
||||
|
||||
assert 'cpu_pct on <a href="/hosts/h1">web-1</a> is 94% (> 90%), firing for 12m' in page
|
||||
assert 'CPU on <a href="/hosts/h1">web-1</a> is 94% (> 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
|
||||
@@ -98,7 +98,7 @@ async def test_a_viewer_reads_but_gets_no_actions(app):
|
||||
page = await render_template("alerts/list.html", active=[_row(rule, "firing", None)],
|
||||
rules=[rule], operators=[])
|
||||
|
||||
assert "cpu_pct on <strong>web-1</strong>" in page # nothing to link to: no bare href
|
||||
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
|
||||
@@ -165,3 +165,43 @@ async def test_rules_say_what_each_is_doing_now(app):
|
||||
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
|
||||
|
||||
@@ -56,3 +56,33 @@ async def test_the_log_continues_itself_instead_of_a_load_more_link(app):
|
||||
'&action=host."') in more
|
||||
assert "Load more" not in more
|
||||
assert "data-scroll-more" not in last and "End of the list." in last
|
||||
|
||||
|
||||
def test_an_entry_reads_as_a_sentence_with_its_detail_in_words():
|
||||
"""#5212: no action codes, Python reprs or raw scopes on the page."""
|
||||
from steward.audit.service import action_text, detail_facts
|
||||
assert action_text("monitor.disabled") == "paused monitor"
|
||||
assert action_text("some.new_thing") == "some.new_thing" # unknown: its code
|
||||
facts = detail_facts({"rule_id": "r1", "check": False, "vars": ["a", "b"], "secret_vars": [],
|
||||
"scope": "steward:group:g1", "interval_seconds": 7200,
|
||||
"section": "ansible.credentials", "run_id": "abc", "limit": None,
|
||||
"carried": {"monitor_results": 12}},
|
||||
{"steward:group:g1": "group Docker Swarm"})
|
||||
said = {f["label"]: f["text"] for f in facts}
|
||||
assert said == {"dry run": "no", "variables": "a, b", "secret variables": "none",
|
||||
"scope": "group Docker Swarm", "every": "2h", "section": "Ansible: credentials",
|
||||
"run": "open", "history carried over": "monitor results 12"}
|
||||
assert [f["url"] for f in facts if f["label"] == "run"] == ["/ansible/runs/abc"]
|
||||
|
||||
|
||||
async def test_the_row_says_what_was_done_not_its_code(app):
|
||||
events = [{"event": _ev("monitor", "m1", "monitor.disabled"), "detail": {},
|
||||
"entity": {"label": "api check", "url": "/monitors/m1", "gone": False},
|
||||
"action": "paused monitor", "facts": [{"label": "dry run", "text": "no", "url": None}]}]
|
||||
async with app.test_request_context("/audit/"):
|
||||
from steward.audit.service import AREAS
|
||||
page = await render_template("audit/list.html", events=events, after=None,
|
||||
action_filter="monitor.", areas=AREAS)
|
||||
assert 'title="monitor.disabled">paused monitor</td>' in page
|
||||
assert "dry run</span>" in page and "False" not in page
|
||||
assert '<option value="monitor." selected>Monitors</option>' in page
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_the_problems_come_first_and_say_since_when(app):
|
||||
problem_card = page[attention:page.index("</section>")]
|
||||
assert "shop-api" in problem_card and "nas" not in problem_card
|
||||
assert "down since <time" in problem_card and ">12m ago</time>" in problem_card
|
||||
assert "CPU high" in problem_card and "cpu_pct > 90" in problem_card and ">4m ago</time>" in problem_card
|
||||
assert "CPU high" in problem_card and "web01 CPU > 90%" in problem_card and ">4m ago</time>" in problem_card
|
||||
assert '<span class="badge badge-crit">Firing</span>' in problem_card
|
||||
# Uptime and TLS coloured by Settings → Thresholds, not by literals.
|
||||
assert '<span class="tone-crit">93.00%</span>' in page and '<span class="tone-ok">100.00%</span>' in page
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""A viewer is not offered what they cannot do (#5212): Schedules and
|
||||
Maintenance draw no Run now / Pause / Edit / Delete / New for them, and
|
||||
say intervals and scopes in words."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from quart import render_template, session
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _window(name, source, resource):
|
||||
return NS(id=f"w-{name}", name=name, scope_source=source, scope_resource=resource,
|
||||
start_at=NOW - timedelta(hours=1), end_at=NOW + timedelta(hours=1))
|
||||
|
||||
|
||||
async def _maintenance(app, role):
|
||||
windows = [_window("patching", "host_agent", None), _window("nas", "http", "nas web")]
|
||||
async with app.test_request_context("/alerts/maintenance"):
|
||||
session["user_role"] = role
|
||||
return await render_template("alerts/maintenance.html", windows=windows, active=windows,
|
||||
upcoming=[], expired=[])
|
||||
|
||||
|
||||
async def test_maintenance_offers_changes_only_to_those_who_can_make_them(app):
|
||||
viewer, operator = await _maintenance(app, "viewer"), await _maintenance(app, "operator")
|
||||
assert "New window" not in viewer and "/delete" not in viewer
|
||||
assert "New window" in operator and operator.count("/delete") == 2
|
||||
assert "Every alert on Host agent" in viewer and "host_agent/*" not in viewer
|
||||
assert "nas web</span>" in viewer and "HTTP monitors" in viewer
|
||||
|
||||
|
||||
async def test_schedules_show_a_viewer_the_list_without_its_controls(app):
|
||||
s = NS(id="s1", name="nightly", enabled=True, source_name="local", playbook_path="prune.yml",
|
||||
inventory_scope="steward:all", interval_seconds=7200, last_error=None, last_run_at=None)
|
||||
rows = [{"s": s, "last_run": None, "next_run": NOW + timedelta(hours=2), "scope_label": "all inventory targets"}]
|
||||
async with app.test_request_context("/ansible/schedules"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("ansible/schedules.html", rows=rows, editing=None)
|
||||
assert "nightly" in page and "every 2h" in page and "7200s" not in page
|
||||
assert ">in 2h</time>" in page or ">in 1h 59m</time>" in page
|
||||
for control in ("Run now", "Pause", "?edit=", "/delete", "New schedule"):
|
||||
assert control not in page, control
|
||||
@@ -68,9 +68,9 @@ def test_a_viewer_reads_the_alert_history(app):
|
||||
page = await resp.get_data(as_text=True)
|
||||
assert "Alert History" in page and 'href="/alerts/"' in page # crumbs back to Alerts
|
||||
assert f"hot {tag}" in page and f"cold-note-{tag}" in page and f"old-{tag}" not in page
|
||||
assert "cpu_pct = 97.5" in page and "rule: > 90" in page
|
||||
assert "CPU 98%" in page and "fires when CPU > 90%" in page
|
||||
assert "email failed" in page and f"mx-{tag}: timed out" in page and "webhook sent" in page
|
||||
assert f"muted · patch-{tag}" in page and "cpu_pct = no data" in page
|
||||
assert f"muted · patch-{tag}" in page and "No CPU reading" in page
|
||||
|
||||
resp = await client.get(f"/alerts/history?range=24h&rule={hot_id}", headers={"HX-Request": "true"})
|
||||
rows = await resp.get_data(as_text=True)
|
||||
@@ -84,7 +84,7 @@ def test_a_viewer_reads_the_alert_history(app):
|
||||
"range": "24h", "rule": hot_id, "before": cursor(now - timedelta(minutes=3), "")})
|
||||
older = await resp.get_data(as_text=True)
|
||||
assert resp.status_code == 200 and "<table" not in older
|
||||
assert "cpu_pct = 97.5" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
|
||||
assert "CPU 98%" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
|
||||
assert "End of the list." in older and "data-scroll-more" not in older
|
||||
|
||||
# The rules list links each rule to its history.
|
||||
|
||||
Reference in New Issue
Block a user