From 75b0233c94bebc8ca84b8e171d06ca96af96cbc9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 19:57:53 -0400 Subject: [PATCH] feat: alert rules and active alerts UI --- fablednetmon/alerts/__init__.py | 0 fablednetmon/alerts/routes.py | 104 ++++++++++++++++++ fablednetmon/templates/alerts/list.html | 92 ++++++++++++++++ fablednetmon/templates/alerts/rules_form.html | 49 +++++++++ 4 files changed, 245 insertions(+) create mode 100644 fablednetmon/alerts/__init__.py create mode 100644 fablednetmon/alerts/routes.py create mode 100644 fablednetmon/templates/alerts/list.html create mode 100644 fablednetmon/templates/alerts/rules_form.html diff --git a/fablednetmon/alerts/__init__.py b/fablednetmon/alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/alerts/routes.py b/fablednetmon/alerts/routes.py new file mode 100644 index 0000000..24267a1 --- /dev/null +++ b/fablednetmon/alerts/routes.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from datetime import datetime, timezone +from quart import Blueprint, render_template, request, redirect, url_for, current_app, session +from sqlalchemy import select +from fablednetmon.auth.middleware import require_role +from fablednetmon.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator +from fablednetmon.models.users import UserRole + +alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") + +_OPERATORS = [(op.value, op.value) for op in AlertOperator] + + +@alerts_bp.get("/") +@require_role(UserRole.viewer) +async def list_alerts(): + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(AlertState, AlertRule) + .join(AlertRule, AlertState.rule_id == AlertRule.id) + .where(AlertState.state.in_([ + AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending + ])) + .order_by(AlertState.last_transition_at.desc()) + ) + active = result.all() + + rules_result = await db.execute( + select(AlertRule).order_by(AlertRule.name) + ) + rules = rules_result.scalars().all() + + return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS) + + +@alerts_bp.get("/rules/new") +@require_role(UserRole.operator) +async def new_rule(): + return await render_template("alerts/rules_form.html", rule=None, operators=_OPERATORS) + + +@alerts_bp.post("/rules") +@require_role(UserRole.operator) +async def create_rule(): + form = await request.form + user_id = session.get("user_id") + now = datetime.now(timezone.utc) + rule = AlertRule( + name=form["name"].strip(), + source_module=form["source_module"].strip(), + resource_name=form["resource_name"].strip(), + metric_name=form["metric_name"].strip(), + operator=AlertOperator(form["operator"]), + threshold=float(form["threshold"]), + consecutive_failures_required=int(form.get("consecutive_failures_required") or 1), + enabled=True, + created_by=user_id, + ) + state = AlertState( + rule_id=rule.id, + state=AlertStateEnum.inactive, + last_transition_at=now, + ) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + db.add(rule) + await db.flush() + state.rule_id = rule.id + db.add(state) + return redirect(url_for("alerts.list_alerts")) + + +@alerts_bp.post("/rules//delete") +@require_role(UserRole.admin) +async def delete_rule(rule_id: str): + async with current_app.db_sessionmaker() as db: + async with db.begin(): + result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) + rule = result.scalar_one_or_none() + if rule: + await db.delete(rule) + return redirect(url_for("alerts.list_alerts")) + + +@alerts_bp.post("//acknowledge") +@require_role(UserRole.operator) +async def acknowledge(rule_id: str): + user_id = session.get("user_id") + now = datetime.now(timezone.utc) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + result = await db.execute( + select(AlertState).where(AlertState.rule_id == rule_id) + ) + state = result.scalar_one_or_none() + if state is None: + return "Not found", 404 + if state.state != AlertStateEnum.firing: + return "Conflict: alert is not in FIRING state", 409 + state.state = AlertStateEnum.acknowledged + state.acknowledged_by = user_id + state.acknowledged_at = now + state.last_transition_at = now + return redirect(url_for("alerts.list_alerts")) diff --git a/fablednetmon/templates/alerts/list.html b/fablednetmon/templates/alerts/list.html new file mode 100644 index 0000000..4824701 --- /dev/null +++ b/fablednetmon/templates/alerts/list.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Alerts — FabledNetMon{% endblock %} +{% block content %} +

Alerts

+ +{% if active %} +

Active

+
+ + + + + + + + + + + + {% for state, rule in active %} + + + + + + + + {% endfor %} + +
StateRuleResourceMetric
+ {% if state.state.value == 'firing' %} + FIRING + {% elif state.state.value == 'acknowledged' %} + ACK + {% else %} + PENDING + {% endif %} + {{ rule.name }}{{ rule.resource_name }}{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} + {% if state.state.value == 'firing' %} +
+ +
+ {% endif %} +
+
+{% else %} +

No active alerts.

+{% endif %} + +
+

Rules

+ New Rule +
+{% if rules %} +
+ + + + + + + + + + + + {% for rule in rules %} + + + + + + + + {% endfor %} + +
NameConditionConsec.Enabled
{{ rule.name }} + {{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }} + {{ rule.operator.value }} {{ rule.threshold }} + {{ rule.consecutive_failures_required }} + {% if rule.enabled %}yes{% else %}no{% endif %} + +
+ +
+
+
+{% else %} +

No alert rules configured.

+{% endif %} +{% endblock %} diff --git a/fablednetmon/templates/alerts/rules_form.html b/fablednetmon/templates/alerts/rules_form.html new file mode 100644 index 0000000..6a7bbcf --- /dev/null +++ b/fablednetmon/templates/alerts/rules_form.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}New Alert Rule — FabledNetMon{% endblock %} +{% block content %} +
+

New Alert Rule

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Cancel +
+
+
+
+{% endblock %}