Merge pull request #122 from christianlouis/codex/alert-history

[codex] add alert history
This commit is contained in:
Christian Krakau-Louis
2026-05-22 23:21:29 +02:00
committed by GitHub
17 changed files with 469 additions and 12 deletions
+30
View File
@@ -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)
+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,
+4
View File
@@ -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 |
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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