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,106 @@
|
||||
"""An alert's history read as episodes (#5316): each time a rule left quiet,
|
||||
what it went through, how long it lasted, its worst reading and who was told.
|
||||
|
||||
The history page lists transitions a page at a time (core.paging), newest
|
||||
first. `episodes` folds one page of them, so an episode can straddle two
|
||||
pages: its end on a newer page (`continues`) or its start on an older one
|
||||
(`began_here` false). Model-free, so it is unit-tested without a database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# The states an alert rests in; leaving one opens an episode, reaching one ends it.
|
||||
QUIET = frozenset({"inactive", "resolved"})
|
||||
# The worst thing an episode went through, most severe first.
|
||||
_SEVERITY = ("firing", "acknowledged", "no_data", "pending")
|
||||
_WORDS = {">": "above", ">=": "at or above", "<": "below", "<=": "at or below",
|
||||
"==": "at", "!=": "other than"}
|
||||
|
||||
|
||||
def fires_when(rule) -> str:
|
||||
"""A rule's condition as a reading's aside: "fires above 3m", "fires
|
||||
when down"."""
|
||||
from steward.alerts.service import rule_condition
|
||||
from steward.core.formatting import metric_value
|
||||
said = rule_condition(rule)
|
||||
if said.startswith("is "):
|
||||
return f"fires when {said[3:]}"
|
||||
op = rule.operator.value
|
||||
return f"fires {_WORDS.get(op, op)} {metric_value(rule.metric_name, rule.threshold)}"
|
||||
|
||||
|
||||
def _worst_value(rule, values: list[float]) -> float | None:
|
||||
"""The reading furthest into breach: the highest for a rule that fires
|
||||
above, the lowest for one that fires below, else the last."""
|
||||
if not values:
|
||||
return None
|
||||
op = rule.operator.value
|
||||
if op in (">", ">="):
|
||||
return max(values)
|
||||
if op in ("<", "<="):
|
||||
return min(values)
|
||||
return values[-1]
|
||||
|
||||
|
||||
def episodes(rows: list[dict], *, newest_page: bool, now: datetime, since: datetime) -> list[dict]:
|
||||
"""Fold transitions (alert history rows, newest first, each with its
|
||||
`rule`, `at`, `from_state`, `to_state`, `raw` value, `deliveries`,
|
||||
`muted_by`, `released`, `ansible_run_id` and `notes`) into episodes,
|
||||
newest first.
|
||||
|
||||
Each episode: rule, steps (oldest first), start, end (None while open),
|
||||
began_here (its first step left quiet on this page), ongoing (open on the
|
||||
newest page: it is still going), continues (open on an older page: it
|
||||
ends on a newer one), worst (the most severe state it reached), duration
|
||||
(seconds, counted from `since` when it began before the range), worst
|
||||
reading, deliveries, muted_by, released and runs."""
|
||||
from steward.core.formatting import metric_value
|
||||
open_by_rule: dict[str, dict] = {}
|
||||
out: list[dict] = []
|
||||
for r in reversed(rows):
|
||||
rid = r["rule"].id
|
||||
ep = open_by_rule.get(rid)
|
||||
if ep is None:
|
||||
ep = {"rule": r["rule"], "steps": [], "start": r["at"], "end": None,
|
||||
"began_here": r["from_state"] in QUIET}
|
||||
out.append(ep)
|
||||
ep["steps"].append(r)
|
||||
if r["to_state"] in QUIET:
|
||||
ep["end"] = r["at"]
|
||||
open_by_rule.pop(rid, None)
|
||||
else:
|
||||
open_by_rule[rid] = ep
|
||||
|
||||
for ep in out:
|
||||
steps, rule = ep["steps"], ep["rule"]
|
||||
reached = {s["to_state"] for s in steps} | {s["from_state"] for s in steps}
|
||||
ep["worst"] = next((s for s in _SEVERITY if s in reached), steps[-1]["to_state"])
|
||||
ep["ongoing"] = ep["end"] is None and newest_page
|
||||
ep["continues"] = ep["end"] is None and not newest_page
|
||||
began = ep["start"] if ep["began_here"] else since
|
||||
ended = ep["end"] or (now if ep["ongoing"] else None)
|
||||
ep["duration"] = (ended - began).total_seconds() if ended else None
|
||||
worst = _worst_value(rule, [s["raw"] for s in steps if s["raw"] is not None])
|
||||
ep["reading"] = None if worst is None else metric_value(rule.metric_name, worst)
|
||||
ep["fires_when"] = fires_when(rule)
|
||||
seen, deliveries = set(), []
|
||||
for s in steps:
|
||||
for d in s["deliveries"]:
|
||||
if (d[0], d[1]) not in seen:
|
||||
seen.add((d[0], d[1]))
|
||||
deliveries.append(d)
|
||||
ep["deliveries"] = deliveries
|
||||
ep["muted_by"] = next((s["muted_by"] for s in steps if s["muted_by"]), None)
|
||||
ep["released"] = any(s["released"] for s in steps)
|
||||
ep["runs"] = [s["ansible_run_id"] for s in steps if s["ansible_run_id"]]
|
||||
ep["notes"] = [n for s in steps for n in s["notes"]]
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
def summary(eps: list[dict]) -> dict:
|
||||
"""How often a rule fired in a range and for how long in all: {fired,
|
||||
seconds, ongoing}."""
|
||||
fired = [e for e in eps if e["worst"] in ("firing", "acknowledged")]
|
||||
return {"fired": len(fired), "seconds": sum(e["duration"] or 0 for e in fired),
|
||||
"ongoing": any(e["ongoing"] for e in fired)}
|
||||
@@ -161,7 +161,7 @@ def _history_row(event, rule) -> dict:
|
||||
return {
|
||||
"at": event.transitioned_at, "rule": rule,
|
||||
"from_state": event.from_state, "to_state": event.to_state,
|
||||
"value": value, "condition": alert_svc.rule_condition(rule),
|
||||
"value": value, "raw": event.metric_value, "condition": alert_svc.rule_condition(rule),
|
||||
"notes": notes, "muted_by": muted_by, "released": released,
|
||||
"deliveries": [(label, sent, error) for label, sent, error in (
|
||||
("email", event.email_sent, event.email_error),
|
||||
@@ -173,18 +173,31 @@ def _history_row(event, rule) -> dict:
|
||||
@alerts_bp.get("/history")
|
||||
@require_role(UserRole.viewer)
|
||||
async def alert_history():
|
||||
"""Every alert transition in a range, newest first; one rule's with ?rule=.
|
||||
A page at a time as the reader scrolls: ?before=<cursor> is the next
|
||||
page's rows alone (core.paging)."""
|
||||
"""Every alert episode in a range, newest first; one rule's with ?rule=,
|
||||
which also says how often it fired and for how long in all (#5316). A
|
||||
page of transitions at a time as the reader scrolls: ?before=<cursor> is
|
||||
the next page's episodes alone (core.paging). The Notified and Action
|
||||
columns show only when something in the range was notified or acted on."""
|
||||
from steward.alerts.episodes import episodes, summary
|
||||
now = datetime.now(timezone.utc)
|
||||
since, range_key = parse_range(request.args.get("range") or "7d")
|
||||
before = request.args.get("before")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
rules = await alert_svc.list_rules(db)
|
||||
chosen = next((r for r in rules if r.id == request.args.get("rule")), None)
|
||||
rows = await alert_svc.history(db, since, chosen.id if chosen else None, PAGE_SIZE, before)
|
||||
rule_id = chosen.id if chosen else None
|
||||
rows = await alert_svc.history(db, since, rule_id, PAGE_SIZE, before)
|
||||
show_notified, show_action = await alert_svc.history_columns(db, since, rule_id)
|
||||
verdict = None
|
||||
if chosen and not before:
|
||||
every = await alert_svc.history(db, since, rule_id, HISTORY_SUMMARY_ROWS)
|
||||
verdict = summary(episodes([_history_row(e, r) for e, r in every], newest_page=True,
|
||||
now=now, since=since))
|
||||
rows, after = next_page(rows, lambda row: (row[0].transitioned_at, row[0].id))
|
||||
ctx = {"events": [_history_row(e, r) for e, r in rows], "after": after,
|
||||
"rules": rules, "chosen": chosen, "current_range": range_key}
|
||||
ctx = {"episodes": episodes([_history_row(e, r) for e, r in rows], newest_page=not before,
|
||||
now=now, since=since),
|
||||
"after": after, "rules": rules, "chosen": chosen, "current_range": range_key,
|
||||
"show_notified": show_notified, "show_action": show_action, "verdict": verdict}
|
||||
if before:
|
||||
return await render_template("alerts/_history_page.html", **ctx)
|
||||
if request.headers.get("HX-Request"):
|
||||
@@ -192,6 +205,12 @@ async def alert_history():
|
||||
return await render_template("alerts/history.html", **ctx)
|
||||
|
||||
|
||||
# How many of one rule's transitions the history verdict reads ("fired N
|
||||
# times, X in all"); a rule that flapped more often than this in the range
|
||||
# is summed over its newest ones.
|
||||
HISTORY_SUMMARY_ROWS = 2000
|
||||
|
||||
|
||||
@alerts_bp.get("/api/rule_fields")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rule_fields_api():
|
||||
|
||||
@@ -299,6 +299,21 @@ async def history(db, since: datetime, rule_id: str | None = None,
|
||||
return [tuple(row) for row in (await db.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def history_columns(db, since: datetime, rule_id: str | None = None) -> tuple[bool, bool]:
|
||||
"""(whether any transition in the range notified someone or was muted,
|
||||
whether any started an Ansible run): the history page shows its Notified
|
||||
and Action columns only then (#5316)."""
|
||||
from steward.core.alerts import MUTED
|
||||
base = select(AlertEvent.id).where(AlertEvent.transitioned_at >= since)
|
||||
if rule_id:
|
||||
base = base.where(AlertEvent.rule_id == rule_id)
|
||||
notified = await db.scalar(select(base.where(or_(
|
||||
AlertEvent.email_sent.is_not(None), AlertEvent.webhook_sent.is_not(None),
|
||||
AlertEvent.note.like(f"%{MUTED}%"))).exists()))
|
||||
acted = await db.scalar(select(base.where(AlertEvent.ansible_run_id.is_not(None)).exists()))
|
||||
return bool(notified), bool(acted)
|
||||
|
||||
|
||||
async def list_maintenance_windows(db) -> list[MaintenanceWindow]:
|
||||
"""Every window, latest start first."""
|
||||
return list((await db.execute(
|
||||
|
||||
Reference in New Issue
Block a user