Merge pull request #122 from christianlouis/codex/alert-history
[codex] add alert history
This commit is contained in:
@@ -65,6 +65,8 @@ You can now:
|
||||
- Integration with [Apprise](https://github.com/caronc/apprise)
|
||||
- Email, Slack, webhook, and more
|
||||
- Alert on new failures, compliance drops, or unknown senders
|
||||
- Daily and weekly DMARC summaries
|
||||
- Alert history for active and resolved alerts
|
||||
|
||||
### 🔐 User Management
|
||||
- Built-in authentication via **FastAPI Users**
|
||||
@@ -100,6 +102,34 @@ Then visit [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Integration with Apprise
|
||||
|
||||
DMARQ sends notifications through Apprise target URLs configured in
|
||||
**Settings** > **Notifications**. Add one target URL per line, enable
|
||||
notifications, and use **Send Test** to verify delivery. Apprise supports email,
|
||||
Slack, Teams, Discord, generic webhooks, and many other targets.
|
||||
|
||||
Notification settings include alert-rule toggles and thresholds for:
|
||||
|
||||
- New sending sources
|
||||
- Compliance-rate drops
|
||||
- DMARC failures above a daily threshold
|
||||
- Missing reports for monitored domains
|
||||
|
||||
DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see
|
||||
the current summary payload, **Send Summary Now** for an immediate message, and
|
||||
the daily/weekly toggles to enable scheduled delivery.
|
||||
|
||||
Alert history is available in **Settings** > **Notifications** after alerts have
|
||||
been evaluated or sent. History rows track active/resolved status, first seen,
|
||||
last seen, observed count, and alert metadata.
|
||||
|
||||
See [Settings](docs/user_guide/settings.md) and
|
||||
[Configuration](docs/deployment/configuration.md) for examples and available
|
||||
settings.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Requirements
|
||||
|
||||
- DMARC aggregate reports (XML, ZIP, or GZIP format)
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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]
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,6 +97,10 @@ from API responses.
|
||||
| `notifications.summary_send_hour_utc` | UTC hour for scheduled summaries | `8` | `7` |
|
||||
| `notifications.summary_weekday_utc` | UTC weekday for weekly summaries, where 0 is Monday | `0` | `4` |
|
||||
|
||||
Alert history is stored in the database-backed `alert_history` table. Current
|
||||
retention is indefinite; prune old resolved rows according to your operational
|
||||
policy if long-term storage size matters.
|
||||
|
||||
### Cloudflare Integration
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|
||||
@@ -83,7 +83,7 @@ Follow-up:
|
||||
|
||||
## Later Milestones
|
||||
|
||||
- Notifications and alert rules. Apprise delivery, test notifications, alert-rule evaluation, and scheduled daily/weekly summaries are in place; alert history remains.
|
||||
- Notifications and alert rules. Apprise delivery, test notifications, alert-rule evaluation, scheduled daily/weekly summaries, and alert history are in place.
|
||||
- DNS health and Cloudflare read-only inspection.
|
||||
- Guided setup and operator health screens.
|
||||
- Forensic/RUF report support.
|
||||
|
||||
+2
-4
@@ -107,7 +107,7 @@ Exit criteria:
|
||||
|
||||
## Milestone 7: Notifications and Alert Rules
|
||||
|
||||
Status: In progress
|
||||
Status: Complete
|
||||
|
||||
Goal: notify administrators when action is needed.
|
||||
|
||||
@@ -117,9 +117,7 @@ Delivered:
|
||||
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
||||
- Notification settings UI can evaluate active alerts and send the current alert summary on demand.
|
||||
- Daily and weekly DMARC summary notifications, including scheduled delivery and manual preview/send controls.
|
||||
|
||||
Planned:
|
||||
- Alert history.
|
||||
- Alert history records active and resolved alerts with first-seen, last-seen, observed-count, and payload metadata.
|
||||
|
||||
Exit criteria:
|
||||
- A user can receive meaningful alerts without opening the dashboard daily.
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
|
||||
- [x] Add Apprise notification delivery and test notification support
|
||||
- [x] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
|
||||
- [x] Add daily and weekly summary notifications
|
||||
- [ ] Add alert history
|
||||
- [x] Add alert history
|
||||
- [ ] DNS health guidance and Cloudflare read-only inspection
|
||||
- [ ] Guided setup and operator health pages
|
||||
- [ ] Forensic/RUF report support
|
||||
|
||||
@@ -63,6 +63,12 @@ Configure daily or weekly summaries in **Settings** > **Notifications**:
|
||||
- **Preview Summary**: shows message volume, report count, and active alert count
|
||||
- **Send Summary Now**: sends the selected daily or weekly summary immediately
|
||||
|
||||
### Alert History
|
||||
|
||||
Alert history appears in **Settings** > **Notifications** after alerts have been
|
||||
evaluated or sent. Each row shows whether the alert is active or resolved, how
|
||||
many times it has been observed, and the latest alert detail.
|
||||
|
||||
### Integration Notifications
|
||||
|
||||
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
||||
|
||||
Reference in New Issue
Block a user