feat: add operations health and domain setup polish
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user