feat: add alert history

This commit is contained in:
Christian Krakau-Louis
2026-05-22 23:18:32 +02:00
parent c384200098
commit af66c93829
17 changed files with 469 additions and 12 deletions
+5 -5
View File
@@ -1,10 +1,8 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -22,8 +20,7 @@ if database_url:
config.set_main_option("sqlalchemy.url", _make_sync_db_url(database_url))
# Import all models so that autogenerate can detect them
from app.core.database import Base # noqa: E402
import app.models.alert # noqa: E402, F401
import app.models.domain # noqa: E402, F401
import app.models.mail_source # noqa: E402, F401
import app.models.mail_source_import # noqa: E402, F401
@@ -31,6 +28,9 @@ import app.models.report # noqa: E402, F401
import app.models.setting # noqa: E402, F401
import app.models.user # noqa: E402, F401
# Import all models so that autogenerate can detect them
from app.core.database import Base # noqa: E402
target_metadata = Base.metadata
@@ -0,0 +1,107 @@
"""add alert history
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Create Date: 2026-05-22 23:12:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a7b8c9d0e1f2"
down_revision: Union[str, Sequence[str], None] = "f6a7b8c9d0e1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create persisted alert history rows."""
op.create_table(
"alert_history",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("fingerprint", sa.String(length=64), nullable=False),
sa.Column("rule", sa.String(), nullable=False),
sa.Column("severity", sa.String(), nullable=False),
sa.Column("domain", sa.String(), nullable=True),
sa.Column("title", sa.String(), nullable=False),
sa.Column("detail", sa.Text(), nullable=False),
sa.Column("payload", sa.Text(), nullable=True),
sa.Column("observed_count", sa.Integer(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("first_seen_at", sa.DateTime(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("fingerprint"),
)
op.create_index(op.f("ix_alert_history_id"), "alert_history", ["id"], unique=False)
op.create_index(
op.f("ix_alert_history_fingerprint"),
"alert_history",
["fingerprint"],
unique=False,
)
op.create_index(op.f("ix_alert_history_rule"), "alert_history", ["rule"], unique=False)
op.create_index(
op.f("ix_alert_history_severity"),
"alert_history",
["severity"],
unique=False,
)
op.create_index(op.f("ix_alert_history_domain"), "alert_history", ["domain"], unique=False)
op.create_index(
op.f("ix_alert_history_is_active"),
"alert_history",
["is_active"],
unique=False,
)
op.create_index(
op.f("ix_alert_history_first_seen_at"),
"alert_history",
["first_seen_at"],
unique=False,
)
op.create_index(
op.f("ix_alert_history_last_seen_at"),
"alert_history",
["last_seen_at"],
unique=False,
)
op.create_index(
op.f("ix_alert_history_resolved_at"),
"alert_history",
["resolved_at"],
unique=False,
)
op.create_index(
"ix_alert_history_active_last_seen",
"alert_history",
["is_active", "last_seen_at"],
unique=False,
)
op.create_index(
"ix_alert_history_rule_domain",
"alert_history",
["rule", "domain"],
unique=False,
)
def downgrade() -> None:
"""Drop persisted alert history rows."""
op.drop_index("ix_alert_history_rule_domain", table_name="alert_history")
op.drop_index("ix_alert_history_active_last_seen", table_name="alert_history")
op.drop_index(op.f("ix_alert_history_resolved_at"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_last_seen_at"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_first_seen_at"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_is_active"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_domain"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_severity"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_rule"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_fingerprint"), table_name="alert_history")
op.drop_index(op.f("ix_alert_history_id"), table_name="alert_history")
op.drop_table("alert_history")
+21 -1
View File
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.setting import Setting
from app.services.alert_history import list_alert_history, record_alert_evaluation
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
from app.services.notifications import send_notification
from app.services.summary_notifications import build_summary, send_summary_notification
@@ -322,6 +323,12 @@ class AlertNotificationResponse(BaseModel):
notification: Dict[str, Any]
class AlertHistoryResponse(BaseModel):
"""Persisted alert history response."""
history: List[Dict[str, Any]]
class SummaryResponse(BaseModel):
"""Current DMARC summary notification preview."""
@@ -387,7 +394,9 @@ async def evaluate_notification_alerts(
) -> AlertRulesResponse:
"""Evaluate enabled notification alert rules against current DMARC data."""
_seed_defaults(db)
return {"alerts": evaluate_alert_rules(db)}
alerts = evaluate_alert_rules(db)
record_alert_evaluation(db, alerts)
return {"alerts": alerts}
@router.post("/notifications/alerts/send", response_model=AlertNotificationResponse)
@@ -407,6 +416,17 @@ async def send_notification_alerts(
return result
@router.get("/notifications/alerts/history", response_model=AlertHistoryResponse)
async def get_notification_alert_history(
active: Optional[bool] = None,
limit: int = 50,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertHistoryResponse:
"""Return persisted alert history rows."""
return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))}
@router.get("/notifications/summary", response_model=SummaryResponse)
async def preview_notification_summary(
period: str = "daily",
+1
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import app.models.alert # noqa: F401 ensure AlertHistory table is registered
import app.models.domain # noqa: F401 ensure Domain/UserDomain tables are registered
import app.models.mail_source_import # noqa: F401 ensure import history table is registered
import app.models.report # noqa: F401 ensure DMARCReport/ReportRecord tables are registered
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text
from app.core.database import Base
class AlertHistory(Base):
"""Persisted alert lifecycle record."""
__tablename__ = "alert_history"
id = Column(Integer, primary_key=True, index=True)
fingerprint = Column(String(64), unique=True, nullable=False, index=True)
rule = Column(String, nullable=False, index=True)
severity = Column(String, nullable=False, index=True)
domain = Column(String, nullable=True, index=True)
title = Column(String, nullable=False)
detail = Column(Text, nullable=False)
payload = Column(Text, nullable=True)
observed_count = Column(Integer, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True, index=True)
first_seen_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
last_seen_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
resolved_at = Column(DateTime, nullable=True, index=True)
__table_args__ = (
Index("ix_alert_history_active_last_seen", "is_active", "last_seen_at"),
Index("ix_alert_history_rule_domain", "rule", "domain"),
)
def __repr__(self):
return f"<AlertHistory {self.rule} active={self.is_active}>"
+127
View File
@@ -0,0 +1,127 @@
"""Persistence helpers for alert history."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.alert import AlertHistory
def _json_dumps(value: Dict[str, Any]) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
def alert_fingerprint(alert: Dict[str, Any]) -> str:
"""Return a stable fingerprint for one alert signal."""
identity = {
"rule": alert.get("rule"),
"domain": alert.get("domain"),
"source_ip": alert.get("source_ip"),
"threshold": alert.get("threshold"),
}
return hashlib.sha256(_json_dumps(identity).encode("utf-8")).hexdigest()
def _row_to_dict(row: AlertHistory) -> Dict[str, Any]:
payload = {}
if row.payload:
try:
payload = json.loads(row.payload)
except json.JSONDecodeError:
payload = {}
return {
"id": row.id,
"fingerprint": row.fingerprint,
"rule": row.rule,
"severity": row.severity,
"domain": row.domain,
"title": row.title,
"detail": row.detail,
"payload": payload,
"observed_count": row.observed_count,
"is_active": row.is_active,
"first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None,
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
"resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
}
def record_alert_evaluation(
db: Session,
alerts: List[Dict[str, Any]],
*,
observed_at: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
"""Upsert current alerts and resolve active alerts that are no longer present."""
timestamp = observed_at or datetime.utcnow()
fingerprints = {alert_fingerprint(alert): alert for alert in alerts}
if fingerprints:
existing_rows = (
db.query(AlertHistory)
.filter(AlertHistory.fingerprint.in_(list(fingerprints.keys())))
.all()
)
else:
existing_rows = []
existing_by_fingerprint = {row.fingerprint: row for row in existing_rows}
for fingerprint, alert in fingerprints.items():
row = existing_by_fingerprint.get(fingerprint)
if row is None:
row = AlertHistory(
fingerprint=fingerprint,
rule=str(alert.get("rule") or "unknown"),
severity=str(alert.get("severity") or "warning"),
domain=alert.get("domain"),
title=str(alert.get("title") or "DMARC alert"),
detail=str(alert.get("detail") or ""),
payload=_json_dumps(alert),
observed_count=1,
is_active=True,
first_seen_at=timestamp,
last_seen_at=timestamp,
)
db.add(row)
continue
row.rule = str(alert.get("rule") or row.rule)
row.severity = str(alert.get("severity") or row.severity)
row.domain = alert.get("domain")
row.title = str(alert.get("title") or row.title)
row.detail = str(alert.get("detail") or row.detail)
row.payload = _json_dumps(alert)
row.observed_count = int(row.observed_count or 0) + 1
row.is_active = True
row.last_seen_at = timestamp
row.resolved_at = None
active_rows = db.query(AlertHistory).filter(AlertHistory.is_active == True).all() # noqa: E712
for row in active_rows:
if row.fingerprint not in fingerprints:
row.is_active = False
row.resolved_at = timestamp
db.commit()
return list_alert_history(db, limit=max(50, len(alerts)))
def list_alert_history(
db: Session,
*,
active: Optional[bool] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Return alert history rows ordered by most recent observation."""
query = db.query(AlertHistory)
if active is not None:
query = query.filter(AlertHistory.is_active == active)
rows = (
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
)
return [_row_to_dict(row) for row in rows]
+2
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.alert_history import record_alert_evaluation
from app.services.notifications import NotificationResult, send_notification
@@ -237,6 +238,7 @@ def evaluate_alert_rules(db: Session) -> List[Dict[str, Any]]:
def send_current_alerts(db: Session) -> Dict[str, Any]:
"""Evaluate current alert rules and send one summary notification when needed."""
alerts = evaluate_alert_rules(db)
record_alert_evaluation(db, alerts)
if not alerts:
return {
"alerts": [],
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.alert_history import record_alert_evaluation
from app.services.alert_rules import evaluate_alert_rules
from app.services.notifications import send_notification
@@ -237,6 +238,7 @@ def format_summary_body(summary: Dict[str, Any]) -> str:
def send_summary_notification(db: Session, period: str = "daily") -> Dict[str, Any]:
"""Build and send a daily or weekly summary notification."""
summary = build_summary(db, period)
record_alert_evaluation(db, summary["alerts"])
title = (
f"DMARQ {period} summary: "
f"{summary['total_messages']} message(s), {len(summary['alerts'])} alert(s)"
+73
View File
@@ -368,6 +368,47 @@
</template>
</div>
<div class="border-t border-border pt-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">Alert History</h3>
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingAlertHistory" @click="loadAlertHistory()">
<template x-if="!loadingAlertHistory">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M12 7v5l4 2"></path>
</svg>
</template>
<template x-if="loadingAlertHistory"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Refresh
</button>
</div>
<template x-if="alertHistory.length === 0">
<div class="alert alert-info">
<span>No alert history yet.</span>
</div>
</template>
<div class="space-y-2" x-show="alertHistory.length > 0">
<template x-for="item in alertHistory" :key="item.id">
<div class="rounded-md border border-border p-3">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-semibold" x-text="item.title"></div>
<div class="text-sm text-muted-foreground" x-text="item.detail"></div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="badge" :class="item.is_active ? 'badge-warning' : 'badge-ghost'" x-text="item.is_active ? 'Active' : 'Resolved'"></span>
<span class="badge badge-outline" x-text="item.observed_count + 'x'"></span>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="flex flex-col sm:flex-row justify-end gap-2">
<button type="button" class="btn btn-outline btn-md" :disabled="saving || previewingSummary" @click="previewSummary()">
<template x-if="!previewingSummary">
@@ -477,6 +518,8 @@ function settingsApp() {
sendingSummary: false,
summaryPeriod: 'daily',
summaryPreview: null,
loadingAlertHistory: false,
alertHistory: [],
showCfToken: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
@@ -500,6 +543,7 @@ function settingsApp() {
const map = {};
rows.forEach(r => { map[r.key] = r.value ?? ''; });
this.s = map;
await this.loadAlertHistory(false);
} catch (err) {
this.showFlash('Error loading settings: ' + err.message, false);
}
@@ -566,6 +610,7 @@ function settingsApp() {
this.showFlash('Alert check failed: ' + (data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
await this.loadAlertHistory(false);
this.showFlash(`${this.alertPreview} active alert${this.alertPreview === 1 ? '' : 's'} found.`, true);
}
} catch (err) {
@@ -590,6 +635,7 @@ function settingsApp() {
this.showFlash('Alert send failed: ' + (notification.message || data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
await this.loadAlertHistory(false);
this.showFlash(data.notification.skipped ? 'No active alerts to send.' : 'Alert summary sent.', true);
}
} catch (err) {
@@ -635,6 +681,7 @@ function settingsApp() {
this.showFlash('Summary send failed: ' + (notification.message || data.detail || res.statusText), false);
} else {
this.summaryPreview = data.summary;
await this.loadAlertHistory(false);
this.showFlash('Summary notification sent.', true);
}
} catch (err) {
@@ -644,6 +691,32 @@ function settingsApp() {
}
},
async loadAlertHistory(showMessage = true) {
this.loadingAlertHistory = true;
try {
const res = await fetch('/api/v1/settings/notifications/alerts/history?limit=10', {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (showMessage) {
this.showFlash('Alert history failed: ' + (data.detail || res.statusText), false);
}
} else {
this.alertHistory = data.history || [];
if (showMessage) {
this.showFlash('Alert history refreshed.', true);
}
}
} catch (err) {
if (showMessage) {
this.showFlash('Error loading alert history: ' + err.message, false);
}
} finally {
this.loadingAlertHistory = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
+1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.models.alert # noqa: F401 # pylint: disable=unused-import
import app.models.domain # noqa: F401 # pylint: disable=unused-import
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
import app.models.mail_source_import # noqa: F401 # pylint: disable=unused-import
+53
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.alert import AlertHistory
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
@@ -416,6 +417,58 @@ class TestSettingsAPI:
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
assert "DMARC failures above threshold" in sent_messages[0]["body"]
def test_notification_alert_history_records_and_resolves_alerts(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert evaluations persist active history and resolve missing alerts."""
domain = _add_domain(db_session, "history.example")
_add_report_record(
db_session,
domain,
report_id="history-failing-day",
days_ago=0,
source_ip="203.0.113.45",
count=200,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
history_res = authed_client.get("/api/v1/settings/notifications/alerts/history")
assert history_res.status_code == 200
history = history_res.json()["history"]
assert history
assert any(item["is_active"] for item in history)
assert db_session.query(AlertHistory).count() == len(history)
authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"notifications.alert_new_sources_enabled": "false",
"notifications.alert_failure_threshold_enabled": "false",
"notifications.alert_missing_reports_enabled": "false",
"notifications.alert_compliance_drop_enabled": "false",
}
},
)
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
assert res.json()["alerts"] == []
resolved_res = authed_client.get(
"/api/v1/settings/notifications/alerts/history?active=false"
)
resolved = resolved_res.json()["history"]
assert resolved
assert all(item["is_active"] is False for item in resolved)
def test_notification_summary_preview_returns_recent_activity(
self,
authed_client: TestClient,