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:
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user