feat(alerts): history reads as episodes; tests follow the confirm dialog (#5316, #5314)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 54s
CI / unit (push) Successful in 1m34s
CI / publish (push) Skipped

History (#5316):
- Transitions fold into episodes (steward/alerts/episodes.py): each stretch
  out of quiet, the worst state it reached, how long it lasted, its worst
  reading against the rule ('3m 21s (fires above 3m)'), who was told across
  its changes, and its Ansible runs. The changes themselves fold under it.
- An episode cut by paging says so ('cleared later (above)', 'earlier').
- For one rule: a verdict ('Fired 4 times in the last 7d, 1h 2m in all') and
  no Rule column. Notified and Action show only when the range has any.

Tests:
- The confirm guard looks for a confirm( call, not the word in the comment
  that says it is gone (lesson #4365).
- The Hosts viewer test looks for the add-host dialog by id, since every page
  now carries the confirm dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:02:01 -04:00
co-authored by Claude Opus 5.5
parent 0c66c1e7d3
commit 531a193bd1
11 changed files with 346 additions and 61 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Alert history read as episodes (#5316): each stretch out of quiet, how
long it lasted, its worst reading, who was told; an episode that straddles a
page of the list says so instead of guessing."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from steward.alerts.episodes import episodes, fires_when, summary
from steward.models.alerts import AlertOperator
NOW = datetime(2026, 10, 8, 12, 0, tzinfo=timezone.utc)
SINCE = NOW - timedelta(days=7)
def _rule(rid="r1", metric="seconds_since_seen", op=AlertOperator.gt, threshold=180.0):
return NS(id=rid, name=f"rule {rid}", metric_name=metric, operator=op, threshold=threshold,
resource_name="VDNT-Docker02", source_module="host_agent")
def _t(rule, mins_ago, frm, to, raw=None, **kw):
return {"rule": rule, "at": NOW - timedelta(minutes=mins_ago), "from_state": frm, "to_state": to,
"raw": raw, "value": f"{raw}", "notes": kw.get("notes", []), "deliveries": kw.get("deliveries", []),
"muted_by": kw.get("muted_by"), "released": False, "ansible_run_id": kw.get("run")}
def _newest_first(*rows):
return sorted(rows, key=lambda r: r["at"], reverse=True)
def test_a_firing_and_its_resolve_are_one_episode():
r = _rule()
eps = episodes(_newest_first(_t(r, 402, "inactive", "firing", 201.0), _t(r, 389, "firing", "resolved", 19.0)),
newest_page=True, now=NOW, since=SINCE)
assert len(eps) == 1
ep = eps[0]
assert ep["worst"] == "firing" and ep["began_here"] and not ep["ongoing"]
assert ep["duration"] == 13 * 60 and ep["reading"] == "3m 21s" # the highest: it fires above
assert ep["fires_when"] == "fires above 3m"
def test_silence_then_firing_is_one_episode_and_the_worst_is_firing():
"""The live rule's shape: inactive → no data, inactive → firing (an
alert at rest records no_data as a label, not a state), no data → firing,
firing → resolved."""
r = _rule()
eps = episodes(_newest_first(
_t(r, 1397, "inactive", "no_data", notes=["no sample for 7m 54s (usually every 1m)"]),
_t(r, 1396, "inactive", "firing", 473.0),
_t(r, 1395, "no_data", "firing", 533.0, notes=["data resumed after 3m 54s silent"]),
_t(r, 1394, "firing", "resolved", 42.0)), newest_page=True, now=NOW, since=SINCE)
assert len(eps) == 1 and len(eps[0]["steps"]) == 4
assert eps[0]["worst"] == "firing" and eps[0]["duration"] == 3 * 60
assert eps[0]["notes"] == ["no sample for 7m 54s (usually every 1m)", "data resumed after 3m 54s silent"]
def test_episodes_are_newest_first_and_kept_apart_per_rule():
a, b = _rule("a"), _rule("b", "cpu_pct", AlertOperator.lt, 5.0)
eps = episodes(_newest_first(
_t(a, 60, "inactive", "firing", 200.0), _t(b, 50, "inactive", "firing", 2.0),
_t(a, 40, "firing", "resolved", 10.0), _t(b, 30, "firing", "resolved", 9.0),
_t(b, 20, "resolved", "firing", 1.0)), newest_page=True, now=NOW, since=SINCE)
assert [(e["rule"].id, e["start"]) for e in eps] == [
("b", NOW - timedelta(minutes=20)), ("b", NOW - timedelta(minutes=50)), ("a", NOW - timedelta(minutes=60))]
assert eps[0]["ongoing"] and eps[0]["duration"] == 20 * 60
assert eps[1]["reading"] == "2.0%" and eps[1]["fires_when"] == "fires below 5.0%" # the lowest: it fires below
def test_an_episode_split_by_paging_says_so():
r = _rule()
# Its end is on a newer page: this older page cannot know when it cleared.
older = episodes([_t(r, 60, "inactive", "firing", 200.0)], newest_page=False, now=NOW, since=SINCE)
assert older[0]["continues"] and not older[0]["ongoing"] and older[0]["duration"] is None
# Its start is on an older page: it began before what this page holds.
newer = episodes([_t(r, 10, "firing", "resolved", 5.0)], newest_page=True, now=NOW, since=SINCE)
assert not newer[0]["began_here"] and newer[0]["end"] == NOW - timedelta(minutes=10)
def test_who_was_told_is_gathered_once_per_episode():
r = _rule()
eps = episodes(_newest_first(
_t(r, 30, "inactive", "firing", 200.0, deliveries=[("email", False, "timed out"), ("webhook", True, None)],
run="run-1"),
_t(r, 20, "firing", "resolved", 5.0, deliveries=[("webhook", True, None)], muted_by="patch")),
newest_page=True, now=NOW, since=SINCE)
assert eps[0]["deliveries"] == [("email", False, "timed out"), ("webhook", True, None)]
assert eps[0]["muted_by"] == "patch" and eps[0]["runs"] == ["run-1"]
def test_the_verdict_counts_what_fired_and_for_how_long():
r = _rule()
eps = episodes(_newest_first(
_t(r, 100, "inactive", "firing", 200.0), _t(r, 90, "firing", "resolved", 5.0),
_t(r, 50, "inactive", "no_data"), _t(r, 45, "inactive", "resolved"),
_t(r, 10, "inactive", "firing", 300.0)), newest_page=True, now=NOW, since=SINCE)
assert summary(eps) == {"fired": 2, "seconds": 10 * 60 + 10 * 60, "ongoing": True}
def test_an_up_or_down_rule_says_the_word():
assert fires_when(_rule(metric="is_up", op=AlertOperator.lt, threshold=0.5)) == "fires when down"
+1 -1
View File
@@ -127,7 +127,7 @@ async def test_add_host_is_a_dialog_on_the_hosts_list(app):
async with app.test_request_context("/hosts/"):
session["user_role"] = "viewer"
viewer = await render_template("hosts/list.html", **_list_ctx(add={}))
assert "<dialog" not in viewer and ">Add host<" not in viewer
assert 'id="add-host"' not in viewer and ">Add host<" not in viewer
async def test_add_host_sends_a_clash_back_with_what_was_typed(app):
+9 -3
View File
@@ -68,14 +68,19 @@ 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 98%" in page and "fires when CPU &gt; 90%" in page
# One episode per stretch out of quiet (#5316): its worst reading against
# the rule, who was told across its changes, the changes folded under it.
assert '98% <span class="tone-dim">(fires above 90%)</span>' 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 "No CPU reading" in page
assert f"muted · patch-{tag}" in page and "No CPU reading" in page and "2 changes of state" in page
assert "<th>Rule</th>" in page and "<th>Notified</th>" in page and "<th>Action</th>" not 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)
assert "<html" not in rows and "<table" in rows
assert f"old-{tag}" in rows and f"cold-note-{tag}" not in rows
assert "<th>Rule</th>" not in rows # one rule: no column for it
assert "Fired 1 time</strong> in the last 24h" in rows # its verdict
# The next page as the reader scrolls: only older rows, without the table
# around them, and the list says where it ends (#5211).
@@ -84,7 +89,8 @@ 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 98%" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
assert "98%" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
assert "cleared later (above)" in older # its end is on the newer page
assert "End of the list." in older and "data-scroll-more" not in older
# The rules list links each rule to its history.
+1 -1
View File
@@ -47,5 +47,5 @@ def test_base_asks_before_a_data_confirm_form_submits():
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 not re.search(r"\bconfirm\s*\(", js + base) # a call, not the comment saying it is gone
assert any("data-confirm=" in p.read_text() for p in _templates())