abfd07331e
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
181 lines
6.4 KiB
Python
181 lines
6.4 KiB
Python
"""Alert management API routes."""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models.alert import AlertRule, AlertHistory, AlertLevel, AlertOperator
|
|
from app.schemas.alert import (
|
|
AlertRuleCreate,
|
|
AlertRuleUpdate,
|
|
AlertRuleResponse,
|
|
AlertHistoryResponse,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
|
|
|
|
|
# ── Rule CRUD ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/rules", response_model=AlertRuleResponse, status_code=201)
|
|
async def create_alert_rule(
|
|
data: AlertRuleCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new alert rule."""
|
|
rule = AlertRule(
|
|
project_id=data.project_id,
|
|
name=data.name,
|
|
level=data.level,
|
|
field=data.field,
|
|
operator=data.operator,
|
|
threshold=data.threshold,
|
|
window_minutes=data.window_minutes,
|
|
notify_channels=",".join(data.notify_channels) if data.notify_channels else "",
|
|
)
|
|
db.add(rule)
|
|
await db.flush()
|
|
await db.refresh(rule)
|
|
return _rule_to_response(rule)
|
|
|
|
|
|
@router.get("/rules", response_model=List[AlertRuleResponse])
|
|
async def list_alert_rules(
|
|
project_id: Optional[str] = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List alert rules, optionally filtered by project."""
|
|
stmt = select(AlertRule)
|
|
if project_id:
|
|
stmt = stmt.where(AlertRule.project_id == project_id)
|
|
stmt = stmt.order_by(AlertRule.created_at.desc())
|
|
result = await db.execute(stmt)
|
|
return [_rule_to_response(r) for r in result.scalars().all()]
|
|
|
|
|
|
@router.get("/rules/{rule_id}", response_model=AlertRuleResponse)
|
|
async def get_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Get a single alert rule."""
|
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
|
rule = result.scalar_one_or_none()
|
|
if not rule:
|
|
raise HTTPException(status_code=404, detail="Alert rule not found")
|
|
return _rule_to_response(rule)
|
|
|
|
|
|
@router.patch("/rules/{rule_id}", response_model=AlertRuleResponse)
|
|
async def update_alert_rule(
|
|
rule_id: str,
|
|
data: AlertRuleUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update an alert rule."""
|
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
|
rule = result.scalar_one_or_none()
|
|
if not rule:
|
|
raise HTTPException(status_code=404, detail="Alert rule not found")
|
|
|
|
if data.name is not None:
|
|
rule.name = data.name
|
|
if data.level is not None:
|
|
rule.level = data.level
|
|
if data.field is not None:
|
|
rule.field = data.field
|
|
if data.operator is not None:
|
|
rule.operator = data.operator
|
|
if data.threshold is not None:
|
|
rule.threshold = data.threshold
|
|
if data.window_minutes is not None:
|
|
rule.window_minutes = data.window_minutes
|
|
if data.is_enabled is not None:
|
|
rule.is_enabled = data.is_enabled
|
|
if data.notify_channels is not None:
|
|
rule.notify_channels = ",".join(data.notify_channels)
|
|
|
|
await db.flush()
|
|
await db.refresh(rule)
|
|
return _rule_to_response(rule)
|
|
|
|
|
|
@router.delete("/rules/{rule_id}", status_code=204)
|
|
async def delete_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Delete an alert rule."""
|
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
|
rule = result.scalar_one_or_none()
|
|
if not rule:
|
|
raise HTTPException(status_code=404, detail="Alert rule not found")
|
|
await db.delete(rule)
|
|
|
|
|
|
# ── Alert History ──────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/history", response_model=List[AlertHistoryResponse])
|
|
async def list_alert_history(
|
|
project_id: Optional[str] = Query(None),
|
|
rule_id: Optional[str] = Query(None),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List alert trigger history."""
|
|
stmt = select(AlertHistory).order_by(AlertHistory.triggered_at.desc()).limit(limit)
|
|
if project_id:
|
|
stmt = stmt.where(AlertHistory.project_id == project_id)
|
|
if rule_id:
|
|
stmt = stmt.where(AlertHistory.rule_id == rule_id)
|
|
result = await db.execute(stmt)
|
|
return [_history_to_response(h) for h in result.scalars().all()]
|
|
|
|
|
|
@router.post("/history/{history_id}/acknowledge", response_model=AlertHistoryResponse)
|
|
async def acknowledge_alert(
|
|
history_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Mark an alert as acknowledged."""
|
|
result = await db.execute(select(AlertHistory).where(AlertHistory.id == history_id))
|
|
history = result.scalar_one_or_none()
|
|
if not history:
|
|
raise HTTPException(status_code=404, detail="Alert history not found")
|
|
history.is_acknowledged = True
|
|
await db.flush()
|
|
await db.refresh(history)
|
|
return _history_to_response(history)
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────
|
|
|
|
|
|
def _rule_to_response(rule: AlertRule) -> AlertRuleResponse:
|
|
return AlertRuleResponse(
|
|
id=rule.id,
|
|
project_id=rule.project_id,
|
|
name=rule.name,
|
|
level=rule.level.value if hasattr(rule.level, "value") else rule.level,
|
|
field=rule.field,
|
|
operator=rule.operator.value if hasattr(rule.operator, "value") else rule.operator,
|
|
threshold=rule.threshold,
|
|
window_minutes=rule.window_minutes,
|
|
is_enabled=rule.is_enabled,
|
|
notify_channels=rule.notify_channels.split(",") if rule.notify_channels else [],
|
|
created_at=rule.created_at,
|
|
)
|
|
|
|
|
|
def _history_to_response(history: AlertHistory) -> AlertHistoryResponse:
|
|
return AlertHistoryResponse(
|
|
id=history.id,
|
|
rule_id=history.rule_id,
|
|
project_id=history.project_id,
|
|
level=history.level.value if hasattr(history.level, "value") else history.level,
|
|
message=history.message,
|
|
triggered_value=history.triggered_value,
|
|
is_acknowledged=history.is_acknowledged,
|
|
triggered_at=history.triggered_at,
|
|
)
|