feat: add operations health and domain setup polish

This commit is contained in:
Christian Krakau-Louis
2026-05-23 11:37:10 +02:00
parent ef506ca654
commit beae6e7469
18 changed files with 622 additions and 42 deletions
+100 -6
View File
@@ -9,9 +9,11 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.domain import Domain
from app.services.cloudflare_dns import (
analyze_dns_records,
@@ -32,6 +34,7 @@ from app.services.report_persistence import (
hydrate_report_store_from_db,
)
from app.services.report_store import ReportStore
from app.utils.domain_validator import validate_domain_config
logger = logging.getLogger(__name__)
@@ -55,6 +58,14 @@ class DomainResponse(DomainBase):
compliance_rate: float = 0.0
class DomainCreate(BaseModel):
"""Payload for creating a monitored domain."""
name: str
description: Optional[str] = None
dkim_selectors: Optional[List[str]] = None
class DomainStatsResponse(BaseModel):
"""Domain statistics for the domain details page"""
@@ -283,6 +294,22 @@ def _policy_enforcement_suggestions(
return []
def _normalize_domain_name(name: str) -> str:
return name.strip().strip(".").lower()
def _domain_names_for_summary(db: Session, store: ReportStore) -> List[str]:
report_domains = store.get_domains()
stored_domains = [
name
for (name,) in db.query(Domain.name)
.filter(Domain.active == True) # noqa: E712
.order_by(Domain.name)
.all()
]
return list(dict.fromkeys(stored_domains + report_domains))
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary(db: Session = Depends(get_db)):
"""
@@ -295,7 +322,7 @@ async def get_domains_summary(db: Session = Depends(get_db)):
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
domains = store.get_domains()
domains = _domain_names_for_summary(db, store)
summaries = store.get_all_domain_summaries()
# Perform DNS checks for all domains, reusing fresh cached results.
@@ -385,19 +412,28 @@ async def get_domains_summary(db: Session = Depends(get_db)):
async def read_domains(db: Session = Depends(get_db)):
"""
Retrieve domains with their statistics.
For Milestone 1, this simply returns domains from the in-memory store.
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
domains = store.get_domains()
domains = _domain_names_for_summary(db, store)
summaries = store.get_all_domain_summaries()
stored = {
domain.name: domain
for domain in db.query(Domain).filter(Domain.name.in_(domains)).all()
}
result = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
stored_domain = stored.get(domain_name)
domain_response = DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
description=stored_domain.description if stored_domain else None,
policy=(
summary.get("policy")
or (stored_domain.dmarc_policy if stored_domain else None)
or "unknown"
),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0),
@@ -407,6 +443,58 @@ async def read_domains(db: Session = Depends(get_db)):
return result
@router.post("/domains", response_model=DomainResponse, status_code=status.HTTP_201_CREATED)
async def create_domain(
payload: DomainCreate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Create a monitored domain before any DMARC reports have arrived."""
name = _normalize_domain_name(payload.name)
validation = validate_domain_config(
{"name": name, "description": payload.description or ""}
)
if not validation["valid"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=validation["errors"],
)
existing = db.query(Domain).filter(Domain.name == name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Domain is already monitored",
)
selectors = ",".join(
selector.strip()
for selector in payload.dkim_selectors or []
if selector and selector.strip()
)
domain = Domain(
name=name,
description=payload.description,
dkim_selectors=selectors or None,
active=True,
verified=False,
)
db.add(domain)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Domain is already monitored",
) from exc
db.refresh(domain)
return DomainResponse(
name=domain.name,
description=domain.description,
policy=domain.dmarc_policy or "unknown",
)
@router.get("/domains/{domain_name}", response_model=DomainResponse)
async def read_domain(domain_name: str, db: Session = Depends(get_db)):
"""
@@ -415,8 +503,9 @@ async def read_domain(domain_name: str, db: Session = Depends(get_db)):
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
domains = store.get_domains()
stored_domain = db.query(Domain).filter(Domain.name == domain_name).first()
if domain_name not in domains:
if domain_name not in domains and stored_domain is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -426,7 +515,12 @@ async def read_domain(domain_name: str, db: Session = Depends(get_db)):
return DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
description=stored_domain.description if stored_domain else None,
policy=(
summary.get("policy")
or (stored_domain.dmarc_policy if stored_domain else None)
or "unknown"
),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0),
+100 -1
View File
@@ -1,6 +1,14 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from sqlalchemy import func, text
from sqlalchemy.orm import Session
from app.api.api_v1.endpoints.setup import setup_status
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.mail_source import MailSource
from app.models.mail_source_import import MailSourceImport
from app.models.report import DMARCReport
from app.services.runtime_status import get_scheduler_status
router = APIRouter()
@@ -17,3 +25,94 @@ async def health_check():
"service": "dmarq",
"is_setup_complete": setup_status["is_setup_complete"],
}
def _iso(value):
return value.isoformat() if value else None
@router.get("/health/operations", status_code=200)
async def operations_health(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Return operational health details for the web health page."""
database = {"ok": True, "detail": "Connected"}
try:
db.execute(text("SELECT 1"))
except Exception as exc: # pylint: disable=broad-exception-caught
database = {"ok": False, "detail": str(exc)}
enabled_sources = 0
total_sources = 0
report_count = 0
latest_report = None
latest_import = None
latest_successful_import = None
if database["ok"]:
enabled_sources = (
db.query(func.count(MailSource.id))
.filter(MailSource.enabled == True) # noqa: E712
.scalar()
)
total_sources = db.query(func.count(MailSource.id)).scalar()
report_count = db.query(func.count(DMARCReport.id)).scalar()
latest_report = db.query(func.max(DMARCReport.processed_at)).scalar()
latest_import = (
db.query(MailSourceImport)
.order_by(MailSourceImport.finished_at.desc(), MailSourceImport.id.desc())
.first()
)
latest_successful_import = (
db.query(MailSourceImport)
.filter(MailSourceImport.status.in_(["success", "warning"]))
.order_by(MailSourceImport.finished_at.desc(), MailSourceImport.id.desc())
.first()
)
scheduler = get_scheduler_status()
status = "ok"
checks = []
if not database["ok"]:
status = "degraded"
checks.append("Database connectivity failed.")
if total_sources and enabled_sources == 0:
status = "degraded"
checks.append("All mail sources are disabled.")
if scheduler.get("last_error"):
status = "degraded"
checks.append("The scheduler reported a recent error.")
return {
"status": status,
"service": "dmarq",
"database": database,
"scheduler": {
**scheduler,
"enabled_sources": int(enabled_sources or 0),
"total_sources": int(total_sources or 0),
},
"imports": {
"latest": {
"status": latest_import.status,
"trigger": latest_import.trigger,
"reports_found": latest_import.reports_found,
"finished_at": _iso(latest_import.finished_at),
}
if latest_import
else None,
"latest_successful": {
"status": latest_successful_import.status,
"trigger": latest_successful_import.trigger,
"reports_found": latest_successful_import.reports_found,
"finished_at": _iso(latest_successful_import.finished_at),
}
if latest_successful_import
else None,
},
"reports": {
"count": int(report_count or 0),
"latest_processed_at": _iso(latest_report),
},
"checks": checks,
}
+10
View File
@@ -7,6 +7,8 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import api_key_header, require_admin_auth, security_bearer
from app.models.domain import Domain
from app.models.mail_source import MailSource
from app.models.setting import Setting
router = APIRouter()
@@ -89,6 +91,9 @@ class SetupStatusResponse(BaseModel):
is_setup_complete: bool
app_name: str
total_domains: int = 0
total_mail_sources: int = 0
enabled_mail_sources: int = 0
class AdminSetupRequest(BaseModel):
@@ -125,6 +130,11 @@ async def get_setup_status(db: Session = Depends(get_db)):
return SetupStatusResponse(
is_setup_complete=current_status["is_setup_complete"],
app_name=current_status["app_name"],
total_domains=db.query(Domain.id).count(),
total_mail_sources=db.query(MailSource.id).count(),
enabled_mail_sources=db.query(MailSource.id)
.filter(MailSource.enabled == True) # noqa: E712
.count(),
)