Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from api.entity_routes import router as entity_router
from api.exceptions import AppError
from api.middleware import ContentTypeValidationMiddleware, LoggingMiddleware, RequestIDMiddleware
from api.policy_routes import router as policy_router
from api.replay_routes import router as replay_router
from api.search_routes import router as search_router
from api.session_routes import router as session_router
Expand Down Expand Up @@ -133,6 +134,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp
app.include_router(cost_router)
app.include_router(search_router)
app.include_router(entity_router)
app.include_router(policy_router)
app.include_router(system_router)
app.include_router(ui_router)

Expand Down
205 changes: 205 additions & 0 deletions api/policy_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Alert policy API routes for configurable alert thresholds."""

from __future__ import annotations

from typing import Any

from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession

from api.dependencies import get_db_session, get_tenant_id
from api.exceptions import NotFoundError
from api.schemas import AlertPolicyCreate, AlertPolicyListResponse, AlertPolicySchema, AlertPolicyUpdate
from storage import AlertPolicyRepository

router = APIRouter(tags=["alert-policies"])


async def get_policy_repository(
session: AsyncSession = Depends(get_db_session),
tenant_id: str = Depends(get_tenant_id),
) -> AlertPolicyRepository:
"""Get an alert policy repository scoped to the current tenant."""
return AlertPolicyRepository(session, tenant_id=tenant_id)


@router.get("/api/alert-policies", response_model=AlertPolicyListResponse)
async def list_policies(
agent_name: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=1000),
repo: AlertPolicyRepository = Depends(get_policy_repository),
) -> AlertPolicyListResponse:
"""List all alert policies, optionally filtered by agent_name.

Args:
agent_name: Optional agent name filter. If provided, returns both
agent-specific and global policies for this agent.
limit: Maximum number of policies to return
repo: AlertPolicyRepository instance

Returns:
List of alert policies
"""
policies = await repo.list_policies(agent_name=agent_name, limit=limit)

return AlertPolicyListResponse(
policies=[
AlertPolicySchema(
id=policy.id,
agent_name=policy.agent_name,
alert_type=policy.alert_type,
threshold_value=policy.threshold_value,
severity_threshold=policy.severity_threshold,
enabled=policy.enabled,
created_at=policy.created_at,
updated_at=policy.updated_at,
)
for policy in policies
],
total=len(policies),
)


@router.post("/api/alert-policies", response_model=AlertPolicySchema)
async def create_policy(
data: AlertPolicyCreate,
repo: AlertPolicyRepository = Depends(get_policy_repository),
) -> AlertPolicySchema:
"""Create a new alert policy.

Args:
data: Policy creation data
repo: AlertPolicyRepository instance

Returns:
Created alert policy
"""
policy = await repo.create_policy(
agent_name=data.agent_name,
alert_type=data.alert_type,
threshold_value=data.threshold_value,
severity_threshold=data.severity_threshold,
enabled=data.enabled,
)
# Commit to persist the policy
await repo.session.commit()
await repo.session.refresh(policy)

return AlertPolicySchema(
id=policy.id,
agent_name=policy.agent_name,
alert_type=policy.alert_type,
threshold_value=policy.threshold_value,
severity_threshold=policy.severity_threshold,
enabled=policy.enabled,
created_at=policy.created_at,
updated_at=policy.updated_at,
)


@router.get("/api/alert-policies/{policy_id}", response_model=AlertPolicySchema)
async def get_policy(
policy_id: str,
repo: AlertPolicyRepository = Depends(get_policy_repository),
) -> AlertPolicySchema:
"""Get a single alert policy by ID.

Args:
policy_id: Unique identifier of the policy
repo: AlertPolicyRepository instance

Returns:
Alert policy details

Raises:
NotFoundError: if policy not found
"""
policy = await repo.get_policy(policy_id)
if not policy:
raise NotFoundError(f"Policy {policy_id} not found")

return AlertPolicySchema(
id=policy.id,
agent_name=policy.agent_name,
alert_type=policy.alert_type,
threshold_value=policy.threshold_value,
severity_threshold=policy.severity_threshold,
enabled=policy.enabled,
created_at=policy.created_at,
updated_at=policy.updated_at,
)


@router.put("/api/alert-policies/{policy_id}", response_model=AlertPolicySchema)
async def update_policy(
policy_id: str,
data: AlertPolicyUpdate,
repo: AlertPolicyRepository = Depends(get_policy_repository),
) -> AlertPolicySchema:
"""Update an existing alert policy.

Args:
policy_id: Unique identifier of the policy to update
data: Policy update data
repo: AlertPolicyRepository instance

Returns:
Updated alert policy

Raises:
NotFoundError: if policy not found
"""
policy = await repo.update_policy(
policy_id=policy_id,
agent_name=data.agent_name,
alert_type=data.alert_type,
threshold_value=data.threshold_value,
severity_threshold=data.severity_threshold,
enabled=data.enabled,
Comment on lines +152 to +158
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve unspecified policy fields on update

AlertPolicyUpdate makes every field optional, but this handler always forwards data.agent_name, data.alert_type, data.threshold_value, etc. to repo.update_policy. For omitted fields, Pydantic supplies None, which bypasses the repository’s _UNSET sentinel and overwrites existing values (including setting non-null columns like alert_type to None, causing a commit-time integrity error/500 on partial updates). This breaks the partial-update contract used by updateAlertPolicy(..., Partial<AlertPolicy>) and can unintentionally clear policy data.

Useful? React with 👍 / 👎.

)

if not policy:
raise NotFoundError(f"Policy {policy_id} not found")

# Commit to persist changes
await repo.session.commit()
await repo.session.refresh(policy)

return AlertPolicySchema(
id=policy.id,
agent_name=policy.agent_name,
alert_type=policy.alert_type,
threshold_value=policy.threshold_value,
severity_threshold=policy.severity_threshold,
enabled=policy.enabled,
created_at=policy.created_at,
updated_at=policy.updated_at,
)


@router.delete("/api/alert-policies/{policy_id}")
async def delete_policy(
policy_id: str,
repo: AlertPolicyRepository = Depends(get_policy_repository),
) -> dict[str, Any]:
"""Delete an alert policy by ID.

Args:
policy_id: Unique identifier of the policy to delete
repo: AlertPolicyRepository instance

Returns:
Deletion confirmation

Raises:
NotFoundError: if policy not found
"""
deleted = await repo.delete_policy(policy_id)

if not deleted:
raise NotFoundError(f"Policy {policy_id} not found")

# Commit to persist deletion
await repo.session.commit()

return {"deleted": True, "policy_id": policy_id}
121 changes: 121 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ class AnomalyAlertSchema(BaseModel):
detection_source: str
detection_config: dict[str, Any]
created_at: datetime
status: str | None = None
acknowledged_at: datetime | None = None
resolved_at: datetime | None = None
dismissed_at: datetime | None = None
resolution_note: str | None = None


class AnomalyAlertListResponse(BaseModel):
Expand All @@ -310,6 +315,77 @@ class AnomalyAlertListResponse(BaseModel):
total: int


# ------------------------------------------------------------------
# Alert Lifecycle Schemas
# ------------------------------------------------------------------


class AlertStatusUpdate(BaseModel):
"""Request schema for updating a single alert's status."""

status: str = Field(min_length=1, max_length=32)
note: str | None = Field(default=None, max_length=2000)


class AlertBulkUpdate(BaseModel):
"""Request schema for bulk updating alert statuses."""

alert_ids: list[str] = Field(min_length=1)
status: str = Field(min_length=1, max_length=32)


class AlertFilters(BaseModel):
"""Query parameters for filtering alerts."""

agent_name: str | None = None
severity: float | None = Field(default=None, ge=0.0, le=1.0)
alert_type: str | None = None
status: str | None = None
from_date: datetime | None = None
to_date: datetime | None = None
limit: int = Field(default=50, ge=1, le=500)


class AlertSeverityCount(BaseModel):
"""Count of alerts by severity level."""

critical: int
high: int
medium: int
low: int


class AlertSummarySchema(BaseModel):
"""Alert summary statistics."""

by_status: dict[str, int]
by_type: dict[str, int]
by_severity: AlertSeverityCount
total: int


class AlertTrendingPointSchema(BaseModel):
"""Single data point for alert trending."""

date: str
count: int


class AlertTrendingSchema(BaseModel):
"""Alert volume over time."""

trending: list[AlertTrendingPointSchema]
days: int


class AlertListFilteredResponse(BaseModel):
"""Response schema for filtered alert listing."""

alerts: list[AnomalyAlertSchema]
total: int
filters: AlertFilters


class FixNoteRequest(BaseModel):
"""Request schema for adding/updating a fix note."""

Expand Down Expand Up @@ -405,3 +481,48 @@ class SimilarFailuresResponse(BaseModel):
failure_event_id: str
similar_failures: list[SimilarFailureSchema]
total: int


# ------------------------------------------------------------------
# Alert policy schemas
# ------------------------------------------------------------------


class AlertPolicyCreate(BaseModel):
"""Request schema for creating an alert policy."""

agent_name: str | None = Field(default=None, max_length=255)
alert_type: str = Field(min_length=1, max_length=64)
threshold_value: float = Field(ge=0.0)
severity_threshold: str | None = Field(default=None, max_length=16)
enabled: bool = Field(default=True)


class AlertPolicyUpdate(BaseModel):
"""Request schema for updating an alert policy."""

agent_name: str | None = Field(default=None, max_length=255)
alert_type: str | None = Field(default=None, min_length=1, max_length=64)
threshold_value: float | None = Field(default=None, ge=0.0)
severity_threshold: str | None = Field(default=None, max_length=16)
enabled: bool | None = None


class AlertPolicySchema(BaseModel):
"""Response schema for alert policies."""

id: str
agent_name: str | None
alert_type: str
threshold_value: float
severity_threshold: str | None
enabled: bool
created_at: datetime
updated_at: datetime


class AlertPolicyListResponse(BaseModel):
"""Response schema for listing alert policies."""

policies: list[AlertPolicySchema]
total: int
Loading
Loading