feat(ui): audit, alert rules and the rule form say things in words (#5212)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Failing after 1m14s
CI / publish (push) Skipped

#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:
2026-10-07 00:23:02 -04:00
co-authored by Claude Opus 5.5
parent 7275fad767
commit 3a772716ae
21 changed files with 487 additions and 156 deletions
+51 -11
View File
@@ -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% (&gt; 90%), firing for 12m' in page
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
@@ -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
+30
View File
@@ -56,3 +56,33 @@ async def test_the_log_continues_itself_instead_of_a_load_more_link(app):
'&amp;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
+1 -1
View File
@@ -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 &gt; 90" in problem_card and ">4m ago</time>" in problem_card
assert "CPU high" in problem_card and "web01 CPU &gt; 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
+43
View File
@@ -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