feat: Add alert endpoints to alert_processor service for dashboard
Implemented missing alert endpoints that the dashboard requires for
health status and action queue functionality.
Alert Processor Service Changes:
- Created alerts_repository.py:
* get_alerts() - Filter alerts by severity/status/resolved with pagination
* get_alerts_summary() - Count alerts by severity and status
* get_alert_by_id() - Get specific alert
- Created alerts.py API endpoints:
* GET /api/v1/tenants/{tenant_id}/alerts/summary - Alert counts
* GET /api/v1/tenants/{tenant_id}/alerts - Filtered alert list
* GET /api/v1/tenants/{tenant_id}/alerts/{alert_id} - Single alert
- Severity mapping: "critical" (dashboard) maps to "urgent" (alert_processor)
- Status enum: active, resolved, acknowledged, ignored
- Severity enum: low, medium, high, urgent
API Server Changes:
- Registered alerts_router in api_server.py
- Exported alerts_router in __init__.py
Procurement Client Changes:
- Updated get_critical_alerts() to use /alerts path
- Updated get_alerts_summary() to use /alerts/summary path
- Added severity mapping (critical → urgent)
- Added documentation about gateway routing
This fixes the 404 errors for alert endpoints in the dashboard.
This commit is contained in:
@@ -3,5 +3,6 @@ Alert Processor API Endpoints
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .analytics import router as analytics_router
|
from .analytics import router as analytics_router
|
||||||
|
from .alerts import router as alerts_router
|
||||||
|
|
||||||
__all__ = ['analytics_router']
|
__all__ = ['analytics_router', 'alerts_router']
|
||||||
|
|||||||
222
services/alert_processor/app/api/alerts.py
Normal file
222
services/alert_processor/app/api/alerts.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# services/alert_processor/app/api/alerts.py
|
||||||
|
"""
|
||||||
|
Alerts API endpoints for dashboard and alert management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from shared.database.base import get_db
|
||||||
|
from app.repositories.alerts_repository import AlertsRepository
|
||||||
|
from app.models.alerts import AlertSeverity, AlertStatus
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Response Models
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class AlertResponse(BaseModel):
|
||||||
|
"""Individual alert response"""
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
item_type: str
|
||||||
|
alert_type: str
|
||||||
|
severity: str
|
||||||
|
status: str
|
||||||
|
service: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
actions: Optional[dict] = None
|
||||||
|
alert_metadata: Optional[dict] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AlertsSummaryResponse(BaseModel):
|
||||||
|
"""Alerts summary for dashboard"""
|
||||||
|
total_count: int = Field(..., description="Total number of alerts")
|
||||||
|
active_count: int = Field(..., description="Number of active (unresolved) alerts")
|
||||||
|
critical_count: int = Field(..., description="Number of critical/urgent alerts")
|
||||||
|
high_count: int = Field(..., description="Number of high severity alerts")
|
||||||
|
medium_count: int = Field(..., description="Number of medium severity alerts")
|
||||||
|
low_count: int = Field(..., description="Number of low severity alerts")
|
||||||
|
resolved_count: int = Field(..., description="Number of resolved alerts")
|
||||||
|
acknowledged_count: int = Field(..., description="Number of acknowledged alerts")
|
||||||
|
|
||||||
|
|
||||||
|
class AlertsListResponse(BaseModel):
|
||||||
|
"""List of alerts with pagination"""
|
||||||
|
alerts: List[AlertResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/tenants/{tenant_id}/alerts/summary",
|
||||||
|
response_model=AlertsSummaryResponse,
|
||||||
|
summary="Get alerts summary",
|
||||||
|
description="Get summary of alerts by severity and status for dashboard health indicator"
|
||||||
|
)
|
||||||
|
async def get_alerts_summary(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> AlertsSummaryResponse:
|
||||||
|
"""
|
||||||
|
Get alerts summary for dashboard
|
||||||
|
|
||||||
|
Returns counts of alerts grouped by severity and status.
|
||||||
|
Critical count maps to URGENT severity for dashboard compatibility.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = AlertsRepository(db)
|
||||||
|
summary = await repo.get_alerts_summary(tenant_id)
|
||||||
|
return AlertsSummaryResponse(**summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting alerts summary", error=str(e), tenant_id=str(tenant_id))
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/tenants/{tenant_id}/alerts",
|
||||||
|
response_model=AlertsListResponse,
|
||||||
|
summary="Get alerts list",
|
||||||
|
description="Get filtered list of alerts with pagination"
|
||||||
|
)
|
||||||
|
async def get_alerts(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
severity: Optional[str] = Query(None, description="Filter by severity: low, medium, high, urgent"),
|
||||||
|
status: Optional[str] = Query(None, description="Filter by status: active, resolved, acknowledged, ignored"),
|
||||||
|
resolved: Optional[bool] = Query(None, description="Filter by resolved status: true=resolved only, false=unresolved only"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
|
||||||
|
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> AlertsListResponse:
|
||||||
|
"""
|
||||||
|
Get filtered list of alerts
|
||||||
|
|
||||||
|
Supports filtering by:
|
||||||
|
- severity: low, medium, high, urgent (maps to "critical" in dashboard)
|
||||||
|
- status: active, resolved, acknowledged, ignored
|
||||||
|
- resolved: boolean filter for resolved status
|
||||||
|
- pagination: limit and offset
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate severity enum
|
||||||
|
if severity and severity not in [s.value for s in AlertSeverity]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid severity. Must be one of: {[s.value for s in AlertSeverity]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate status enum
|
||||||
|
if status and status not in [s.value for s in AlertStatus]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid status. Must be one of: {[s.value for s in AlertStatus]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
repo = AlertsRepository(db)
|
||||||
|
alerts = await repo.get_alerts(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
severity=severity,
|
||||||
|
status=status,
|
||||||
|
resolved=resolved,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to response models
|
||||||
|
alert_responses = [
|
||||||
|
AlertResponse(
|
||||||
|
id=str(alert.id),
|
||||||
|
tenant_id=str(alert.tenant_id),
|
||||||
|
item_type=alert.item_type,
|
||||||
|
alert_type=alert.alert_type,
|
||||||
|
severity=alert.severity,
|
||||||
|
status=alert.status,
|
||||||
|
service=alert.service,
|
||||||
|
title=alert.title,
|
||||||
|
message=alert.message,
|
||||||
|
actions=alert.actions,
|
||||||
|
alert_metadata=alert.alert_metadata,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
updated_at=alert.updated_at,
|
||||||
|
resolved_at=alert.resolved_at
|
||||||
|
)
|
||||||
|
for alert in alerts
|
||||||
|
]
|
||||||
|
|
||||||
|
return AlertsListResponse(
|
||||||
|
alerts=alert_responses,
|
||||||
|
total=len(alert_responses), # In a real implementation, you'd query the total count separately
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting alerts", error=str(e), tenant_id=str(tenant_id))
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}",
|
||||||
|
response_model=AlertResponse,
|
||||||
|
summary="Get alert by ID",
|
||||||
|
description="Get a specific alert by its ID"
|
||||||
|
)
|
||||||
|
async def get_alert(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
alert_id: UUID = Path(..., description="Alert ID"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> AlertResponse:
|
||||||
|
"""Get a specific alert by ID"""
|
||||||
|
try:
|
||||||
|
repo = AlertsRepository(db)
|
||||||
|
alert = await repo.get_alert_by_id(tenant_id, alert_id)
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
return AlertResponse(
|
||||||
|
id=str(alert.id),
|
||||||
|
tenant_id=str(alert.tenant_id),
|
||||||
|
item_type=alert.item_type,
|
||||||
|
alert_type=alert.alert_type,
|
||||||
|
severity=alert.severity,
|
||||||
|
status=alert.status,
|
||||||
|
service=alert.service,
|
||||||
|
title=alert.title,
|
||||||
|
message=alert.message,
|
||||||
|
actions=alert.actions,
|
||||||
|
alert_metadata=alert.alert_metadata,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
updated_at=alert.updated_at,
|
||||||
|
resolved_at=alert.resolved_at
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting alert", error=str(e), alert_id=str(alert_id))
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.config import AlertProcessorConfig
|
from app.config import AlertProcessorConfig
|
||||||
from app.api import analytics_router
|
from app.api import analytics_router, alerts_router
|
||||||
from shared.database.base import create_database_manager
|
from shared.database.base import create_database_manager
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
@@ -31,6 +31,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(analytics_router, tags=["analytics"])
|
app.include_router(analytics_router, tags=["analytics"])
|
||||||
|
app.include_router(alerts_router, tags=["alerts"])
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
config = AlertProcessorConfig()
|
config = AlertProcessorConfig()
|
||||||
|
|||||||
178
services/alert_processor/app/repositories/alerts_repository.py
Normal file
178
services/alert_processor/app/repositories/alerts_repository.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# services/alert_processor/app/repositories/alerts_repository.py
|
||||||
|
"""
|
||||||
|
Alerts Repository - Database access layer for alerts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_, or_
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.models.alerts import Alert, AlertStatus, AlertSeverity
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class AlertsRepository:
|
||||||
|
"""Repository for alert database operations"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_alerts(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
severity: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
resolved: Optional[bool] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[Alert]:
|
||||||
|
"""
|
||||||
|
Get alerts with optional filters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant UUID
|
||||||
|
severity: Filter by severity (low, medium, high, urgent)
|
||||||
|
status: Filter by status (active, resolved, acknowledged, ignored)
|
||||||
|
resolved: Filter by resolved status (True = resolved, False = not resolved, None = all)
|
||||||
|
limit: Maximum number of results
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Alert objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = select(Alert).where(Alert.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if severity:
|
||||||
|
query = query.where(Alert.severity == severity)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(Alert.status == status)
|
||||||
|
|
||||||
|
if resolved is not None:
|
||||||
|
if resolved:
|
||||||
|
query = query.where(Alert.status == AlertStatus.RESOLVED.value)
|
||||||
|
else:
|
||||||
|
query = query.where(Alert.status != AlertStatus.RESOLVED.value)
|
||||||
|
|
||||||
|
# Order by created_at descending (newest first)
|
||||||
|
query = query.order_by(Alert.created_at.desc())
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
alerts = result.scalars().all()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Retrieved alerts",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
count=len(alerts),
|
||||||
|
filters={"severity": severity, "status": status, "resolved": resolved}
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(alerts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error retrieving alerts", error=str(e), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_alerts_summary(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get summary of alerts by severity and status
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with counts by severity and status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Count by severity
|
||||||
|
severity_query = (
|
||||||
|
select(
|
||||||
|
Alert.severity,
|
||||||
|
func.count(Alert.id).label("count")
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Alert.tenant_id == tenant_id,
|
||||||
|
Alert.status != AlertStatus.RESOLVED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(Alert.severity)
|
||||||
|
)
|
||||||
|
|
||||||
|
severity_result = await self.db.execute(severity_query)
|
||||||
|
severity_counts = {row[0]: row[1] for row in severity_result.all()}
|
||||||
|
|
||||||
|
# Count by status
|
||||||
|
status_query = (
|
||||||
|
select(
|
||||||
|
Alert.status,
|
||||||
|
func.count(Alert.id).label("count")
|
||||||
|
)
|
||||||
|
.where(Alert.tenant_id == tenant_id)
|
||||||
|
.group_by(Alert.status)
|
||||||
|
)
|
||||||
|
|
||||||
|
status_result = await self.db.execute(status_query)
|
||||||
|
status_counts = {row[0]: row[1] for row in status_result.all()}
|
||||||
|
|
||||||
|
# Count active alerts (not resolved)
|
||||||
|
active_count = sum(
|
||||||
|
count for status, count in status_counts.items()
|
||||||
|
if status != AlertStatus.RESOLVED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map to expected field names (dashboard expects "critical")
|
||||||
|
summary = {
|
||||||
|
"total_count": sum(status_counts.values()),
|
||||||
|
"active_count": active_count,
|
||||||
|
"critical_count": severity_counts.get(AlertSeverity.URGENT.value, 0), # Map URGENT to critical
|
||||||
|
"high_count": severity_counts.get(AlertSeverity.HIGH.value, 0),
|
||||||
|
"medium_count": severity_counts.get(AlertSeverity.MEDIUM.value, 0),
|
||||||
|
"low_count": severity_counts.get(AlertSeverity.LOW.value, 0),
|
||||||
|
"resolved_count": status_counts.get(AlertStatus.RESOLVED.value, 0),
|
||||||
|
"acknowledged_count": status_counts.get(AlertStatus.ACKNOWLEDGED.value, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Retrieved alerts summary",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
summary=summary
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error retrieving alerts summary", error=str(e), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_alert_by_id(self, tenant_id: UUID, alert_id: UUID) -> Optional[Alert]:
|
||||||
|
"""Get a specific alert by ID"""
|
||||||
|
try:
|
||||||
|
query = select(Alert).where(
|
||||||
|
and_(
|
||||||
|
Alert.tenant_id == tenant_id,
|
||||||
|
Alert.id == alert_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if alert:
|
||||||
|
logger.info("Retrieved alert", alert_id=str(alert_id), tenant_id=str(tenant_id))
|
||||||
|
else:
|
||||||
|
logger.warning("Alert not found", alert_id=str(alert_id), tenant_id=str(tenant_id))
|
||||||
|
|
||||||
|
return alert
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error retrieving alert", error=str(e), alert_id=str(alert_id))
|
||||||
|
raise
|
||||||
@@ -611,6 +611,8 @@ class ProcurementServiceClient(BaseServiceClient):
|
|||||||
"""
|
"""
|
||||||
Get critical alerts for dashboard
|
Get critical alerts for dashboard
|
||||||
|
|
||||||
|
Note: "critical" maps to "urgent" severity in alert_processor service
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tenant_id: Tenant ID
|
tenant_id: Tenant ID
|
||||||
limit: Maximum number of alerts to return
|
limit: Maximum number of alerts to return
|
||||||
@@ -619,10 +621,12 @@ class ProcurementServiceClient(BaseServiceClient):
|
|||||||
Dict with {"alerts": [...], "total": n}
|
Dict with {"alerts": [...], "total": n}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Gateway routes /tenants/{tenant_id}/alerts/... to alert_processor service
|
||||||
|
# "critical" in dashboard = "urgent" severity in alert_processor
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/procurement/alert-processor/alerts",
|
"/alerts",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
params={"severity": "critical", "resolved": False, "limit": limit}
|
params={"severity": "urgent", "resolved": False, "limit": limit}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error fetching critical alerts", error=str(e), tenant_id=tenant_id)
|
logger.error("Error fetching critical alerts", error=str(e), tenant_id=tenant_id)
|
||||||
@@ -639,11 +643,12 @@ class ProcurementServiceClient(BaseServiceClient):
|
|||||||
tenant_id: Tenant ID
|
tenant_id: Tenant ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with counts by severity
|
Dict with counts by severity (critical_count maps to urgent severity)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Gateway routes /tenants/{tenant_id}/alerts/... to alert_processor service
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/procurement/alert-processor/alerts/summary",
|
"/alerts/summary",
|
||||||
tenant_id=tenant_id
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user